initial version of the app
This commit is contained in:
@@ -0,0 +1,14 @@
|
|||||||
|
# Database
|
||||||
|
DATABASE_URL="./sqlite.db"
|
||||||
|
|
||||||
|
# NextAuth.js
|
||||||
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
|
NEXTAUTH_SECRET="your-secret-key-here-make-this-very-long-and-random"
|
||||||
|
|
||||||
|
# Email Configuration (Gmail)
|
||||||
|
EMAIL_USER="your-email@gmail.com"
|
||||||
|
EMAIL_PASSWORD="your-app-password-here"
|
||||||
|
|
||||||
|
# Admin Configuration
|
||||||
|
ADMIN_EMAIL="admin@example.com"
|
||||||
|
ADMIN_PASSWORD="admin123"
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
# Use the official Node.js runtime as the base image
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# Set the working directory in the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package.json and package-lock.json (if available)
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
# Copy the rest of the application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create the SQLite database directory
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
# Build the Next.js application
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Expose the port the app runs on
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV DATABASE_URL=/app/data/sqlite.db
|
||||||
|
|
||||||
|
# Create a non-root user to run the application
|
||||||
|
RUN addgroup -g 1001 -S nodejs
|
||||||
|
RUN adduser -S nextjs -u 1001
|
||||||
|
|
||||||
|
# Change ownership of the app directory to the nextjs user
|
||||||
|
RUN chown -R nextjs:nodejs /app
|
||||||
|
USER nextjs
|
||||||
|
|
||||||
|
# Command to run the application
|
||||||
|
CMD ["npm", "start"]
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# Table Tennis Booking System
|
||||||
|
|
||||||
|
A modern, full-stack table tennis court booking system built with Next.js, shadcn/ui, and SQLite.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### User Features
|
||||||
|
|
||||||
|
- **Secure Authentication**: User registration and login with JWT tokens
|
||||||
|
- **Court Booking**: Interactive booking calendar with real-time availability
|
||||||
|
- **Email Notifications**: Automatic confirmation and cancellation emails
|
||||||
|
- **Mobile-First Design**: Responsive UI that works on all devices
|
||||||
|
- **Booking Management**: View and manage your bookings
|
||||||
|
|
||||||
|
### Admin Features
|
||||||
|
|
||||||
|
- **Court Management**: Add/remove courts and configure availability
|
||||||
|
- **Time Slot Configuration**: Set operating hours for different days
|
||||||
|
- **User Management**: View and manage user accounts
|
||||||
|
- **Booking Override**: Admin can edit or cancel any booking
|
||||||
|
- **Announcements**: Create and manage system announcements
|
||||||
|
- **Activity Logs**: Comprehensive logging of all system activities
|
||||||
|
- **Analytics Dashboard**: Booking statistics and usage metrics
|
||||||
|
|
||||||
|
### System Features
|
||||||
|
|
||||||
|
- **7-Day Booking Window**: Users can only book up to 1 week in advance
|
||||||
|
- **Real-time Validation**: Both client and server-side booking validation
|
||||||
|
- **Secure Backend**: SQLite database with Drizzle ORM
|
||||||
|
- **Docker Support**: Easy deployment with Docker and reverse proxy
|
||||||
|
- **Email Integration**: Gmail SMTP integration for notifications
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- **Frontend**: Next.js 14, React, TypeScript
|
||||||
|
- **UI Components**: shadcn/ui, Tailwind CSS, Radix UI
|
||||||
|
- **Backend**: Next.js API routes, Drizzle ORM
|
||||||
|
- **Database**: SQLite
|
||||||
|
- **Authentication**: JWT tokens with httpOnly cookies
|
||||||
|
- **Email**: Nodemailer with Gmail
|
||||||
|
- **Deployment**: Docker, Nginx reverse proxy
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm or yarn
|
||||||
|
- Gmail account for email notifications
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
1. **Clone the repository**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd tt-booking
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Set up environment variables**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env.local` with your configuration:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXTAUTH_SECRET="your-secret-key-here-make-this-very-long-and-random"
|
||||||
|
EMAIL_USER="your-email@gmail.com"
|
||||||
|
EMAIL_PASSWORD="your-gmail-app-password"
|
||||||
|
ADMIN_EMAIL="admin@example.com"
|
||||||
|
ADMIN_PASSWORD="admin123"
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Set up the database**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:push
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Run the development server**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Access the application**
|
||||||
|
- User interface: http://localhost:3000
|
||||||
|
- Admin panel: http://localhost:3000/admin
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Gmail Setup
|
||||||
|
|
||||||
|
1. Enable 2-factor authentication on your Gmail account
|
||||||
|
2. Generate an App Password: Google Account > Security > App passwords
|
||||||
|
3. Use the App Password as `EMAIL_PASSWORD` in your environment variables
|
||||||
|
|
||||||
|
### Default Settings
|
||||||
|
|
||||||
|
- **Courts**: 2 courts (configurable via admin panel)
|
||||||
|
- **Monday/Tuesday**: 19:00-23:00 (configurable)
|
||||||
|
- **Sunday**: 12:00-17:00 (configurable)
|
||||||
|
- **Booking window**: 7 days from current date
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
1. **Update environment variables** in `docker-compose.yml`
|
||||||
|
2. **Configure SSL certificates** in the `ssl` directory
|
||||||
|
3. **Update domain** in `nginx.conf`
|
||||||
|
4. **Deploy**:
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker-compose.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
tt-booking/
|
||||||
|
├── app/ # Next.js app directory
|
||||||
|
│ ├── api/ # API routes
|
||||||
|
│ ├── dashboard/ # User dashboard
|
||||||
|
│ ├── admin/ # Admin panel
|
||||||
|
│ └── layout.tsx # Root layout
|
||||||
|
├── components/ # React components
|
||||||
|
│ ├── ui/ # shadcn/ui components
|
||||||
|
│ ├── auth/ # Authentication forms
|
||||||
|
│ ├── booking/ # Booking components
|
||||||
|
│ └── admin/ # Admin components
|
||||||
|
├── lib/ # Utility libraries
|
||||||
|
│ ├── db/ # Database schema and connection
|
||||||
|
│ ├── auth.ts # Authentication utilities
|
||||||
|
│ ├── email.ts # Email functionality
|
||||||
|
│ └── utils.ts # General utilities
|
||||||
|
├── docker-compose.yml # Docker configuration
|
||||||
|
├── Dockerfile # Container definition
|
||||||
|
└── nginx.conf # Reverse proxy configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
- `POST /api/auth/login` - User login
|
||||||
|
- `POST /api/auth/register` - User registration
|
||||||
|
- `POST /api/auth/logout` - User logout
|
||||||
|
|
||||||
|
### Bookings
|
||||||
|
|
||||||
|
- `GET /api/bookings` - Get user bookings
|
||||||
|
- `POST /api/bookings` - Create booking
|
||||||
|
- `PUT /api/bookings/[id]` - Update booking
|
||||||
|
- `DELETE /api/bookings/[id]` - Cancel booking
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
|
||||||
|
- `GET /api/admin/stats` - Dashboard statistics
|
||||||
|
- `GET /api/admin/courts` - Manage courts
|
||||||
|
- `GET /api/admin/settings` - System settings
|
||||||
|
- `GET /api/admin/logs` - Activity logs
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
- **Rate Limiting**: API endpoints are rate-limited via Nginx
|
||||||
|
- **CSRF Protection**: Built-in Next.js CSRF protection
|
||||||
|
- **SQL Injection Prevention**: Drizzle ORM parameterized queries
|
||||||
|
- **XSS Protection**: Content Security Policy headers
|
||||||
|
- **Secure Cookies**: httpOnly, secure, sameSite cookies
|
||||||
|
- **Input Validation**: Zod schema validation
|
||||||
|
- **Password Hashing**: bcrypt with salt rounds
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Fork the repository
|
||||||
|
2. Create a feature branch
|
||||||
|
3. Make your changes
|
||||||
|
4. Add tests if applicable
|
||||||
|
5. Submit a pull request
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License.
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and questions, please create an issue in the repository.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { AdminDashboard } from '@/components/admin/admin-dashboard';
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
return <AdminDashboard />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { announcements } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, content, priority, expiresAt, isActive } = await request.json();
|
||||||
|
const announcementId = params.id;
|
||||||
|
|
||||||
|
if (!title || !content) {
|
||||||
|
return NextResponse.json({ error: 'Title and content are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if announcement exists
|
||||||
|
const existing = await db.select().from(announcements).where(eq(announcements.id, announcementId)).limit(1);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Announcement not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update announcement
|
||||||
|
const [updatedAnnouncement] = await db
|
||||||
|
.update(announcements)
|
||||||
|
.set({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
priority: priority || 'medium',
|
||||||
|
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||||
|
isActive: isActive !== undefined ? isActive : true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(announcements.id, announcementId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
announcement: updatedAnnouncement,
|
||||||
|
message: 'Announcement updated successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating announcement:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const announcementId = params.id;
|
||||||
|
|
||||||
|
// Check if announcement exists
|
||||||
|
const existing = await db.select().from(announcements).where(eq(announcements.id, announcementId)).limit(1);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Announcement not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete announcement
|
||||||
|
await db.delete(announcements).where(eq(announcements.id, announcementId));
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Announcement deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting announcement:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { announcements } from '@/lib/db/schema';
|
||||||
|
import { eq, desc } from 'drizzle-orm';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular users see only active announcements, admins see all
|
||||||
|
const allAnnouncements = await db
|
||||||
|
.select()
|
||||||
|
.from(announcements)
|
||||||
|
.where(session.role === 'admin' ? undefined : eq(announcements.isActive, true))
|
||||||
|
.orderBy(desc(announcements.createdAt));
|
||||||
|
|
||||||
|
return NextResponse.json({ announcements: allAnnouncements });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching announcements:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, content, priority, expiresAt } = await request.json();
|
||||||
|
|
||||||
|
if (!title || !content) {
|
||||||
|
return NextResponse.json({ error: 'Title and content are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newAnnouncement] = await db
|
||||||
|
.insert(announcements)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
priority: priority || 'medium',
|
||||||
|
expiresAt: expiresAt ? new Date(expiresAt) : null,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
announcement: newAnnouncement,
|
||||||
|
message: 'Announcement created successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating announcement:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { courts } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, isActive } = await request.json();
|
||||||
|
const courtId = params.id;
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json({ error: 'Court name is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if court exists
|
||||||
|
const existing = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Court not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update court
|
||||||
|
const [updatedCourt] = await db
|
||||||
|
.update(courts)
|
||||||
|
.set({
|
||||||
|
name,
|
||||||
|
isActive: isActive !== undefined ? isActive : true,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(courts.id, courtId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
court: updatedCourt,
|
||||||
|
message: 'Court updated successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating court:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const courtId = params.id;
|
||||||
|
|
||||||
|
// Check if court exists
|
||||||
|
const existing = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
|
||||||
|
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Court not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete court (this will cascade to bookings due to foreign key)
|
||||||
|
await db.delete(courts).where(eq(courts.id, courtId));
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Court deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting court:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { courts } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular users see only active courts, admins see all
|
||||||
|
const allCourts = await db
|
||||||
|
.select()
|
||||||
|
.from(courts)
|
||||||
|
.where(session.role === 'admin' ? undefined : eq(courts.isActive, true));
|
||||||
|
|
||||||
|
return NextResponse.json({ courts: allCourts });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching courts:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, isActive } = await request.json();
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return NextResponse.json({ error: 'Court name is required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [newCourt] = await db
|
||||||
|
.insert(courts)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
isActive: isActive !== undefined ? isActive : true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
court: newCourt,
|
||||||
|
message: 'Court created successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating court:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { verifySession } from '@/lib/session';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { activityLogs, users } from '@/lib/db/schema';
|
||||||
|
import { eq, desc, isNull, or } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await verifySession();
|
||||||
|
if (!session.isAuth || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '20');
|
||||||
|
const offset = parseInt(searchParams.get('offset') || '0');
|
||||||
|
|
||||||
|
// Get activity logs with user details
|
||||||
|
const logs = await db
|
||||||
|
.select({
|
||||||
|
id: activityLogs.id,
|
||||||
|
action: activityLogs.action,
|
||||||
|
entityType: activityLogs.entityType,
|
||||||
|
entityId: activityLogs.entityId,
|
||||||
|
details: activityLogs.details,
|
||||||
|
ipAddress: activityLogs.ipAddress,
|
||||||
|
userAgent: activityLogs.userAgent,
|
||||||
|
createdAt: activityLogs.createdAt,
|
||||||
|
user: {
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
surname: users.surname,
|
||||||
|
email: users.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(activityLogs)
|
||||||
|
.leftJoin(users, eq(activityLogs.userId, users.id))
|
||||||
|
.orderBy(desc(activityLogs.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
logs,
|
||||||
|
pagination: {
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
hasMore: logs.length === limit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching activity logs:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch activity logs' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { action, entityType, entityId, details, ipAddress, userAgent } = body;
|
||||||
|
|
||||||
|
if (!action || !entityType) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await verifySession();
|
||||||
|
const userId = session.isAuth ? session.userId : null;
|
||||||
|
|
||||||
|
// Create activity log
|
||||||
|
const [log] = await db
|
||||||
|
.insert(activityLogs)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
action,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
details: details ? JSON.stringify(details) : null,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
log,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating activity log:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to create activity log' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { verifySession } from '@/lib/session';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { bookings, users, courts } from '@/lib/db/schema';
|
||||||
|
import { eq, desc, gte } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await verifySession();
|
||||||
|
if (!session.isAuth || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const limit = parseInt(searchParams.get('limit') || '10');
|
||||||
|
|
||||||
|
// Get recent bookings with user and court details
|
||||||
|
const recentBookings = await db
|
||||||
|
.select({
|
||||||
|
id: bookings.id,
|
||||||
|
date: bookings.date,
|
||||||
|
startTime: bookings.startTime,
|
||||||
|
endTime: bookings.endTime,
|
||||||
|
status: bookings.status,
|
||||||
|
notes: bookings.notes,
|
||||||
|
createdAt: bookings.createdAt,
|
||||||
|
court: {
|
||||||
|
id: courts.id,
|
||||||
|
name: courts.name,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
surname: users.surname,
|
||||||
|
email: users.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(bookings)
|
||||||
|
.innerJoin(courts, eq(bookings.courtId, courts.id))
|
||||||
|
.innerJoin(users, eq(bookings.userId, users.id))
|
||||||
|
.orderBy(desc(bookings.createdAt))
|
||||||
|
.limit(limit);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
bookings: recentBookings,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching admin recent bookings:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch recent bookings' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { settings } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allSettings = await db.select().from(settings);
|
||||||
|
|
||||||
|
// Convert to key-value object for easier use
|
||||||
|
const settingsObj = allSettings.reduce((acc: any, setting) => {
|
||||||
|
acc[setting.key] = setting.value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return NextResponse.json({ settings: allSettings });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching settings:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { settings: newSettings } = await request.json();
|
||||||
|
|
||||||
|
if (!newSettings || !Array.isArray(newSettings)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid settings format. Expected array of {key, value} objects' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update each setting
|
||||||
|
const updatePromises = newSettings.map(async ({ key, value }) => {
|
||||||
|
if (!key || value === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to update existing setting first
|
||||||
|
const result = await db
|
||||||
|
.update(settings)
|
||||||
|
.set({
|
||||||
|
value: String(value),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(settings.key, key));
|
||||||
|
|
||||||
|
// If no rows were updated, insert new setting
|
||||||
|
if (!result.changes) {
|
||||||
|
await db.insert(settings).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
key,
|
||||||
|
value: String(value),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(updatePromises);
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Settings updated successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating settings:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { users } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, surname, email, role, password } = await request.json();
|
||||||
|
const userId = params.id;
|
||||||
|
|
||||||
|
if (!name || !surname || !email) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const existingUser = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||||
|
|
||||||
|
if (existingUser.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare update data
|
||||||
|
const updateData: any = {
|
||||||
|
name,
|
||||||
|
surname,
|
||||||
|
email,
|
||||||
|
role: role || 'user',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only hash and update password if provided
|
||||||
|
if (password) {
|
||||||
|
updateData.password = await bcrypt.hash(password, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user
|
||||||
|
const [updatedUser] = await db.update(users).set(updateData).where(eq(users.id, userId)).returning({
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
surname: users.surname,
|
||||||
|
email: users.email,
|
||||||
|
role: users.role,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
updatedAt: users.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ user: updatedUser, message: 'User updated successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = params.id;
|
||||||
|
|
||||||
|
// Prevent admin from deleting themselves
|
||||||
|
if (session.userId === userId) {
|
||||||
|
return NextResponse.json({ error: 'Cannot delete your own account' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
const existingUser = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||||
|
|
||||||
|
if (existingUser.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete user
|
||||||
|
await db.delete(users).where(eq(users.id, userId));
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'User deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting user:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { users } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const allUsers = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
surname: users.surname,
|
||||||
|
email: users.email,
|
||||||
|
role: users.role,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
})
|
||||||
|
.from(users);
|
||||||
|
|
||||||
|
return NextResponse.json({ users: allUsers });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, surname, email, password, role } = await request.json();
|
||||||
|
|
||||||
|
if (!name || !surname || !email || !password) {
|
||||||
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||||
|
|
||||||
|
if (existingUser.length > 0) {
|
||||||
|
return NextResponse.json({ error: 'User with this email already exists' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
const [newUser] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name,
|
||||||
|
surname,
|
||||||
|
email,
|
||||||
|
password: hashedPassword,
|
||||||
|
role: role || 'user',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
surname: users.surname,
|
||||||
|
email: users.email,
|
||||||
|
role: users.role,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ user: newUser, message: 'User created successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating user:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { announcements } from '@/lib/db/schema';
|
||||||
|
import { desc, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all active announcements, ordered by creation date (newest first)
|
||||||
|
const allAnnouncements = await db
|
||||||
|
.select({
|
||||||
|
id: announcements.id,
|
||||||
|
title: announcements.title,
|
||||||
|
content: announcements.content,
|
||||||
|
priority: announcements.priority,
|
||||||
|
isActive: announcements.isActive,
|
||||||
|
createdAt: announcements.createdAt,
|
||||||
|
})
|
||||||
|
.from(announcements)
|
||||||
|
.where(eq(announcements.isActive, true))
|
||||||
|
.orderBy(desc(announcements.createdAt));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
announcements: allAnnouncements,
|
||||||
|
unreadCount: allAnnouncements.length, // For now, all announcements are considered unread
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching announcements:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { users } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { createSession } from '@/lib/session';
|
||||||
|
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { email, password } = await request.json();
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return NextResponse.json({ error: 'Email and password are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find user by email
|
||||||
|
const user = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
||||||
|
|
||||||
|
if (user.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
const isValid = await bcrypt.compare(password, user[0].password);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
await createSession({
|
||||||
|
userId: user[0].id,
|
||||||
|
email: user[0].email,
|
||||||
|
role: user[0].role as 'user' | 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log the login activity
|
||||||
|
await logActivity({
|
||||||
|
userId: user[0].id,
|
||||||
|
action: ACTIONS.USER_LOGIN,
|
||||||
|
entityType: ENTITY_TYPES.USER,
|
||||||
|
entityId: user[0].id,
|
||||||
|
details: {
|
||||||
|
email: user[0].email,
|
||||||
|
role: user[0].role,
|
||||||
|
},
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: user[0].id,
|
||||||
|
email: user[0].email,
|
||||||
|
name: user[0].name,
|
||||||
|
surname: user[0].surname,
|
||||||
|
role: user[0].role,
|
||||||
|
},
|
||||||
|
message: 'Login successful',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { deleteSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
await deleteSession();
|
||||||
|
return NextResponse.json({ message: 'Logout successful' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { users } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { createSession } from '@/lib/session';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const registerSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
name: z.string().min(1),
|
||||||
|
surname: z.string().min(1),
|
||||||
|
password: z.string().min(6),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const validatedData = registerSchema.parse(body);
|
||||||
|
|
||||||
|
// Check if user already exists
|
||||||
|
const existingUser = await db.select().from(users).where(eq(users.email, validatedData.email)).limit(1);
|
||||||
|
if (existingUser.length > 0) {
|
||||||
|
return NextResponse.json({ error: 'User with this email already exists' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
const hashedPassword = await bcrypt.hash(validatedData.password, 10);
|
||||||
|
|
||||||
|
// Create new user
|
||||||
|
const [newUser] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
email: validatedData.email,
|
||||||
|
name: validatedData.name,
|
||||||
|
surname: validatedData.surname,
|
||||||
|
password: hashedPassword,
|
||||||
|
role: 'user',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Create session
|
||||||
|
await createSession({
|
||||||
|
userId: newUser.id,
|
||||||
|
email: newUser.email,
|
||||||
|
role: newUser.role as 'user' | 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
id: newUser.id,
|
||||||
|
email: newUser.email,
|
||||||
|
name: newUser.name,
|
||||||
|
surname: newUser.surname,
|
||||||
|
role: newUser.role,
|
||||||
|
},
|
||||||
|
message: 'User created successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: 'Invalid input data', details: error.errors }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Registration error:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { bookings } from '@/lib/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { notes } = await request.json();
|
||||||
|
const bookingId = params.id;
|
||||||
|
|
||||||
|
// Check if booking exists and belongs to user
|
||||||
|
const existingBooking = await db
|
||||||
|
.select()
|
||||||
|
.from(bookings)
|
||||||
|
.where(and(eq(bookings.id, bookingId), eq(bookings.userId, session.userId), eq(bookings.status, 'active')))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingBooking.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Booking not found or access denied' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const booking = existingBooking[0];
|
||||||
|
|
||||||
|
// Check if booking can be modified (more than 2 hours before start time)
|
||||||
|
const bookingDateTime = new Date(`${booking.date}T${booking.startTime}`);
|
||||||
|
const now = new Date();
|
||||||
|
const timeDiff = bookingDateTime.getTime() - now.getTime();
|
||||||
|
const hoursDiff = timeDiff / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
if (hoursDiff <= 2) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Booking can only be modified more than 2 hours before the session' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update booking notes
|
||||||
|
await db
|
||||||
|
.update(bookings)
|
||||||
|
.set({
|
||||||
|
notes: notes || null,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(bookings.id, bookingId));
|
||||||
|
|
||||||
|
// Log the activity
|
||||||
|
await logActivity({
|
||||||
|
userId: session.userId,
|
||||||
|
action: ACTIONS.BOOKING_UPDATE,
|
||||||
|
entityType: ENTITY_TYPES.BOOKING,
|
||||||
|
entityId: bookingId,
|
||||||
|
details: {
|
||||||
|
oldNotes: booking.notes,
|
||||||
|
newNotes: notes,
|
||||||
|
date: booking.date,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
},
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Booking updated successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating booking:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingId = params.id;
|
||||||
|
|
||||||
|
// Check if booking exists and belongs to user
|
||||||
|
const existingBooking = await db
|
||||||
|
.select()
|
||||||
|
.from(bookings)
|
||||||
|
.where(and(eq(bookings.id, bookingId), eq(bookings.userId, session.userId), eq(bookings.status, 'active')))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingBooking.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Booking not found or access denied' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const booking = existingBooking[0];
|
||||||
|
|
||||||
|
// Check if booking can be cancelled (more than 2 hours before start time)
|
||||||
|
const bookingDateTime = new Date(`${booking.date}T${booking.startTime}`);
|
||||||
|
const now = new Date();
|
||||||
|
const timeDiff = bookingDateTime.getTime() - now.getTime();
|
||||||
|
const hoursDiff = timeDiff / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
if (hoursDiff <= 2) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Booking can only be cancelled more than 2 hours before the session' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel booking (set status to cancelled)
|
||||||
|
await db
|
||||||
|
.update(bookings)
|
||||||
|
.set({
|
||||||
|
status: 'cancelled',
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(bookings.id, bookingId));
|
||||||
|
|
||||||
|
// Log the activity
|
||||||
|
await logActivity({
|
||||||
|
userId: session.userId,
|
||||||
|
action: ACTIONS.BOOKING_CANCEL,
|
||||||
|
entityType: ENTITY_TYPES.BOOKING,
|
||||||
|
entityId: bookingId,
|
||||||
|
details: {
|
||||||
|
date: booking.date,
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
courtId: booking.courtId,
|
||||||
|
},
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Booking cancelled successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cancelling booking:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { bookings, courts } from '@/lib/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userBookings = await db
|
||||||
|
.select({
|
||||||
|
id: bookings.id,
|
||||||
|
courtId: bookings.courtId,
|
||||||
|
date: bookings.date,
|
||||||
|
startTime: bookings.startTime,
|
||||||
|
endTime: bookings.endTime,
|
||||||
|
status: bookings.status,
|
||||||
|
createdAt: bookings.createdAt,
|
||||||
|
})
|
||||||
|
.from(bookings)
|
||||||
|
.where(eq(bookings.userId, session.userId));
|
||||||
|
|
||||||
|
return NextResponse.json({ bookings: userBookings });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching bookings:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { courtId, date, timeSlot } = await request.json();
|
||||||
|
|
||||||
|
if (!courtId || !date || !timeSlot) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Missing required fields: courtId, date, timeSlot',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse timeSlot (e.g., "14:00") to get start and end times
|
||||||
|
const startTime = timeSlot;
|
||||||
|
const [hours, minutes] = timeSlot.split(':').map(Number);
|
||||||
|
const endTime = `${String(hours + 1).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Validate booking date is not in the past
|
||||||
|
const bookingDate = new Date(date);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (bookingDate < today) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Cannot book dates in the past',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if court exists and is active
|
||||||
|
const court = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
|
||||||
|
if (court.length === 0 || !court[0].isActive) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Court not found or inactive',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if slot is already booked
|
||||||
|
const existingBooking = await db
|
||||||
|
.select()
|
||||||
|
.from(bookings)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(bookings.courtId, courtId),
|
||||||
|
eq(bookings.date, date),
|
||||||
|
eq(bookings.startTime, startTime),
|
||||||
|
eq(bookings.status, 'active')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingBooking.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Time slot already booked',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the booking
|
||||||
|
const [newBooking] = await db
|
||||||
|
.insert(bookings)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: session.userId,
|
||||||
|
courtId,
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
booking: newBooking,
|
||||||
|
message: 'Booking created successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating booking:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { bookings, courts } from '@/lib/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userBookings = await db
|
||||||
|
.select({
|
||||||
|
id: bookings.id,
|
||||||
|
courtId: bookings.courtId,
|
||||||
|
date: bookings.date,
|
||||||
|
startTime: bookings.startTime,
|
||||||
|
endTime: bookings.endTime,
|
||||||
|
status: bookings.status,
|
||||||
|
createdAt: bookings.createdAt,
|
||||||
|
})
|
||||||
|
.from(bookings)
|
||||||
|
.where(eq(bookings.userId, session.userId));
|
||||||
|
|
||||||
|
return NextResponse.json({ bookings: userBookings });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching bookings:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { courtId, date, timeSlot } = await request.json();
|
||||||
|
|
||||||
|
if (!courtId || !date || !timeSlot) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Missing required fields: courtId, date, timeSlot',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse timeSlot (e.g., "14:00") to get start and end times
|
||||||
|
const startTime = timeSlot;
|
||||||
|
const [hours, minutes] = timeSlot.split(':').map(Number);
|
||||||
|
const endTime = `${String(hours + 1).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Validate booking date is not in the past
|
||||||
|
const bookingDate = new Date(date);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (bookingDate < today) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Cannot book dates in the past',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if court exists and is active
|
||||||
|
const court = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
|
||||||
|
if (court.length === 0 || !court[0].isActive) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Court not found or inactive',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if slot is already booked
|
||||||
|
const existingBooking = await db
|
||||||
|
.select()
|
||||||
|
.from(bookings)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(bookings.courtId, courtId),
|
||||||
|
eq(bookings.date, date),
|
||||||
|
eq(bookings.startTime, startTime),
|
||||||
|
eq(bookings.status, 'active')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingBooking.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Time slot already booked',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the booking
|
||||||
|
const [newBooking] = await db
|
||||||
|
.insert(bookings)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: session.userId,
|
||||||
|
courtId,
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
booking: newBooking,
|
||||||
|
message: 'Booking created successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating booking:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { bookings, courts } from '@/lib/db/schema';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userBookings = await db
|
||||||
|
.select({
|
||||||
|
id: bookings.id,
|
||||||
|
courtId: bookings.courtId,
|
||||||
|
date: bookings.date,
|
||||||
|
startTime: bookings.startTime,
|
||||||
|
endTime: bookings.endTime,
|
||||||
|
status: bookings.status,
|
||||||
|
notes: bookings.notes,
|
||||||
|
createdAt: bookings.createdAt,
|
||||||
|
court: {
|
||||||
|
id: courts.id,
|
||||||
|
name: courts.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(bookings)
|
||||||
|
.innerJoin(courts, eq(bookings.courtId, courts.id))
|
||||||
|
.where(eq(bookings.userId, session.userId));
|
||||||
|
|
||||||
|
return NextResponse.json({ bookings: userBookings });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching bookings:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { courtId, date, timeSlot } = await request.json();
|
||||||
|
|
||||||
|
if (!courtId || !date || !timeSlot) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Missing required fields: courtId, date, timeSlot',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse timeSlot (e.g., "14:00") to get start and end times
|
||||||
|
const startTime = timeSlot;
|
||||||
|
const [hours, minutes] = timeSlot.split(':').map(Number);
|
||||||
|
const endTime = `${String(hours + 1).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Validate booking date is not in the past
|
||||||
|
const bookingDate = new Date(date);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (bookingDate < today) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Cannot book dates in the past',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if court exists and is active
|
||||||
|
const court = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
|
||||||
|
if (court.length === 0 || !court[0].isActive) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Court not found or inactive',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if slot is already booked
|
||||||
|
const existingBooking = await db
|
||||||
|
.select()
|
||||||
|
.from(bookings)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(bookings.courtId, courtId),
|
||||||
|
eq(bookings.date, date),
|
||||||
|
eq(bookings.startTime, startTime),
|
||||||
|
eq(bookings.status, 'active')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingBooking.length > 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Time slot already booked',
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the booking
|
||||||
|
const [newBooking] = await db
|
||||||
|
.insert(bookings)
|
||||||
|
.values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId: session.userId,
|
||||||
|
courtId,
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
status: 'active',
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Log the activity
|
||||||
|
await logActivity({
|
||||||
|
userId: session.userId,
|
||||||
|
action: ACTIONS.BOOKING_CREATE,
|
||||||
|
entityType: ENTITY_TYPES.BOOKING,
|
||||||
|
entityId: newBooking.id,
|
||||||
|
details: {
|
||||||
|
courtId,
|
||||||
|
courtName: court[0].name,
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
},
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
booking: newBooking,
|
||||||
|
message: 'Booking created successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating booking:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { courts } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all active courts (users can read courts)
|
||||||
|
const activeCourts = await db.select().from(courts).where(eq(courts.isActive, true));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
courts: activeCourts,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching courts:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { verifySession } from '@/lib/session';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { bookings, users, courts } from '@/lib/db/schema';
|
||||||
|
import { eq, and, desc, gte } from 'drizzle-orm';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await verifySession();
|
||||||
|
if (!session.isAuth || !session.userId) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = today.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Get recent bookings for the current user with court and user details
|
||||||
|
const recentBookings = await db
|
||||||
|
.select({
|
||||||
|
id: bookings.id,
|
||||||
|
date: bookings.date,
|
||||||
|
startTime: bookings.startTime,
|
||||||
|
endTime: bookings.endTime,
|
||||||
|
status: bookings.status,
|
||||||
|
notes: bookings.notes,
|
||||||
|
createdAt: bookings.createdAt,
|
||||||
|
court: {
|
||||||
|
id: courts.id,
|
||||||
|
name: courts.name,
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
surname: users.surname,
|
||||||
|
email: users.email,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(bookings)
|
||||||
|
.innerJoin(courts, eq(bookings.courtId, courts.id))
|
||||||
|
.innerJoin(users, eq(bookings.userId, users.id))
|
||||||
|
.where(
|
||||||
|
and(eq(bookings.userId, session.userId), eq(bookings.status, 'active'), gte(bookings.date, todayStr))
|
||||||
|
)
|
||||||
|
.orderBy(desc(bookings.createdAt))
|
||||||
|
.limit(5);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
bookings: recentBookings,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching recent bookings:', error);
|
||||||
|
return NextResponse.json({ error: 'Failed to fetch recent bookings' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { users, bookings, courts } from '@/lib/db/schema';
|
||||||
|
import { eq, count, and, gte } from 'drizzle-orm';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current date
|
||||||
|
const today = new Date();
|
||||||
|
const todayStr = today.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Get total users count
|
||||||
|
const [totalUsersResult] = await db.select({ count: count() }).from(users);
|
||||||
|
|
||||||
|
// Get today's bookings count
|
||||||
|
const [todayBookingsResult] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(bookings)
|
||||||
|
.where(and(eq(bookings.date, todayStr), eq(bookings.status, 'active')));
|
||||||
|
|
||||||
|
// Get active courts count
|
||||||
|
const [activeCourtsResult] = await db.select({ count: count() }).from(courts).where(eq(courts.isActive, true));
|
||||||
|
|
||||||
|
// Get user's total bookings
|
||||||
|
const [userBookingsResult] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(bookings)
|
||||||
|
.where(and(eq(bookings.userId, session.userId), eq(bookings.status, 'active')));
|
||||||
|
|
||||||
|
// Get user's upcoming bookings (today and future)
|
||||||
|
const [upcomingBookingsResult] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(bookings)
|
||||||
|
.where(
|
||||||
|
and(eq(bookings.userId, session.userId), gte(bookings.date, todayStr), eq(bookings.status, 'active'))
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
stats: {
|
||||||
|
totalUsers: totalUsersResult.count,
|
||||||
|
todayBookings: todayBookingsResult.count,
|
||||||
|
activeCourts: activeCourtsResult.count,
|
||||||
|
userBookings: userBookingsResult.count,
|
||||||
|
upcomingBookings: upcomingBookingsResult.count,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard stats:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { settings } from '@/lib/db/schema';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all settings (users can read settings but not modify them)
|
||||||
|
const allSettings = await db.select().from(settings);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
settings: allSettings,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching settings:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { users } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user profile
|
||||||
|
const [user] = await db
|
||||||
|
.select({
|
||||||
|
id: users.id,
|
||||||
|
email: users.email,
|
||||||
|
name: users.name,
|
||||||
|
surname: users.surname,
|
||||||
|
role: users.role,
|
||||||
|
createdAt: users.createdAt,
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.id, session.userId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: {
|
||||||
|
...user,
|
||||||
|
createdAt: user.createdAt.toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user profile:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { name, surname } = await request.json();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!name || !surname) {
|
||||||
|
return NextResponse.json({ error: 'Name and surname are required' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current user data for logging
|
||||||
|
const [currentUser] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1);
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user profile
|
||||||
|
await db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
name: name.trim(),
|
||||||
|
surname: surname.trim(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(users.id, session.userId));
|
||||||
|
|
||||||
|
// Log the activity
|
||||||
|
await logActivity({
|
||||||
|
userId: session.userId,
|
||||||
|
action: ACTIONS.USER_UPDATE,
|
||||||
|
entityType: ENTITY_TYPES.USER,
|
||||||
|
entityId: session.userId,
|
||||||
|
details: {
|
||||||
|
previousData: {
|
||||||
|
name: currentUser.name,
|
||||||
|
surname: currentUser.surname,
|
||||||
|
},
|
||||||
|
newData: {
|
||||||
|
name: name.trim(),
|
||||||
|
surname: surname.trim(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
request,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Profile updated successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user profile:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
import { DashboardHeader } from '@/components/dashboard/dashboard-header';
|
||||||
|
import { EnhancedBookingCalendar } from '@/components/booking/enhanced-booking-calendar';
|
||||||
|
import { UserBookingManagement } from '@/components/booking/user-booking-management';
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100'>
|
||||||
|
<DashboardHeader user={session} />
|
||||||
|
|
||||||
|
<main className='container mx-auto px-4 py-8'>
|
||||||
|
<div className='grid gap-8 lg:grid-cols-3'>
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className='lg:col-span-2 space-y-6'>
|
||||||
|
<div>
|
||||||
|
<h1 className='text-3xl font-bold text-gray-900 mb-2'>
|
||||||
|
Welcome back, {session.email.split('@')[0]}! 🏓
|
||||||
|
</h1>
|
||||||
|
<p className='text-gray-600'>Book your table tennis court and enjoy your game</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EnhancedBookingCalendar />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<UserBookingManagement />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--background: 0 0% 100%;
|
||||||
|
--foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--card: 0 0% 100%;
|
||||||
|
--card-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--popover: 0 0% 100%;
|
||||||
|
--popover-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--primary: 222.2 47.4% 11.2%;
|
||||||
|
--primary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--secondary: 210 40% 96%;
|
||||||
|
--secondary-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--muted: 210 40% 96%;
|
||||||
|
--muted-foreground: 215.4 16.3% 46.9%;
|
||||||
|
|
||||||
|
--accent: 210 40% 96%;
|
||||||
|
--accent-foreground: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--destructive: 0 84.2% 60.2%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 214.3 31.8% 91.4%;
|
||||||
|
--input: 214.3 31.8% 91.4%;
|
||||||
|
--ring: 222.2 84% 4.9%;
|
||||||
|
|
||||||
|
--radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: 222.2 84% 4.9%;
|
||||||
|
--foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--card: 222.2 84% 4.9%;
|
||||||
|
--card-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--popover: 222.2 84% 4.9%;
|
||||||
|
--popover-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--primary: 210 40% 98%;
|
||||||
|
--primary-foreground: 222.2 47.4% 11.2%;
|
||||||
|
|
||||||
|
--secondary: 217.2 32.6% 17.5%;
|
||||||
|
--secondary-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--muted: 217.2 32.6% 17.5%;
|
||||||
|
--muted-foreground: 215 20.2% 65.1%;
|
||||||
|
|
||||||
|
--accent: 217.2 32.6% 17.5%;
|
||||||
|
--accent-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--destructive: 0 62.8% 30.6%;
|
||||||
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
|
||||||
|
--border: 217.2 32.6% 17.5%;
|
||||||
|
--input: 217.2 32.6% 17.5%;
|
||||||
|
--ring: 212.7 26.8% 83.9%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import './globals.css';
|
||||||
|
import type { Metadata } from 'next';
|
||||||
|
import { Inter } from 'next/font/google';
|
||||||
|
import { ThemeProvider } from '@/components/theme-provider';
|
||||||
|
import { Toaster } from '@/components/ui/toaster';
|
||||||
|
|
||||||
|
const inter = Inter({ subsets: ['latin'] });
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Table Tennis Booking System',
|
||||||
|
description: 'Book your table tennis court slots with ease',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang='en' suppressHydrationWarning>
|
||||||
|
<body className={inter.className}>
|
||||||
|
<ThemeProvider attribute='class' defaultTheme='light' enableSystem disableTransitionOnChange>
|
||||||
|
{children}
|
||||||
|
<Toaster />
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { LoginForm } from '@/components/auth/LoginForm';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<div className='min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center px-4'>
|
||||||
|
<div className='w-full max-w-md space-y-6'>
|
||||||
|
<div className='text-center'>
|
||||||
|
<h1 className='text-3xl font-bold text-gray-900 mb-2'>🏓 TT Booking</h1>
|
||||||
|
<p className='text-gray-600'>Professional table tennis court booking system</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LoginForm />
|
||||||
|
|
||||||
|
<div className='text-center text-sm'>
|
||||||
|
<span className='text-gray-600'>Don't have an account? </span>
|
||||||
|
<Link href='/register' className='text-blue-600 hover:text-blue-800 font-medium'>
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const session = await getSession();
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
if (session.role === 'admin') {
|
||||||
|
redirect('/admin');
|
||||||
|
} else {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
redirect('/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { RegisterForm } from '@/components/auth/RegisterForm';
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
return (
|
||||||
|
<div className='min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 flex items-center justify-center px-4'>
|
||||||
|
<div className='w-full max-w-md space-y-6'>
|
||||||
|
<div className='text-center'>
|
||||||
|
<h1 className='text-3xl font-bold text-gray-900 mb-2'>🏓 TT Booking</h1>
|
||||||
|
<p className='text-gray-600'>Join our table tennis community</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RegisterForm />
|
||||||
|
|
||||||
|
<div className='text-center text-sm'>
|
||||||
|
<span className='text-gray-600'>Already have an account? </span>
|
||||||
|
<Link href='/login' className='text-blue-600 hover:text-blue-800 font-medium'>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { Megaphone, Plus, Edit, Trash2, Calendar, AlertCircle, CheckCircle, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Announcement {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
priority: 'low' | 'medium' | 'high';
|
||||||
|
isActive: boolean;
|
||||||
|
expiresAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnouncementFormData {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
priority: 'low' | 'medium' | 'high';
|
||||||
|
isActive: boolean;
|
||||||
|
expiresAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminAnnouncementManagement() {
|
||||||
|
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [editingAnnouncement, setEditingAnnouncement] = useState<Announcement | null>(null);
|
||||||
|
const [formData, setFormData] = useState<AnnouncementFormData>({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
priority: 'medium',
|
||||||
|
isActive: true,
|
||||||
|
expiresAt: '',
|
||||||
|
});
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAnnouncements();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAnnouncements = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/admin/announcements');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setAnnouncements(data.announcements);
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to fetch announcements');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching announcements:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch announcements',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateAnnouncement = async () => {
|
||||||
|
try {
|
||||||
|
if (!formData.title || !formData.content) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Please fill in title and content',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitData = {
|
||||||
|
...formData,
|
||||||
|
expiresAt: formData.expiresAt || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/admin/announcements', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(submitData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Announcement created successfully',
|
||||||
|
});
|
||||||
|
setIsCreateDialogOpen(false);
|
||||||
|
resetForm();
|
||||||
|
fetchAnnouncements();
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Failed to create announcement',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating announcement:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to create announcement',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditAnnouncement = async () => {
|
||||||
|
try {
|
||||||
|
if (!editingAnnouncement || !formData.title || !formData.content) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Please fill in title and content',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitData = {
|
||||||
|
...formData,
|
||||||
|
expiresAt: formData.expiresAt || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`/api/admin/announcements/${editingAnnouncement.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(submitData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Announcement updated successfully',
|
||||||
|
});
|
||||||
|
setIsEditDialogOpen(false);
|
||||||
|
setEditingAnnouncement(null);
|
||||||
|
resetForm();
|
||||||
|
fetchAnnouncements();
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Failed to update announcement',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating announcement:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to update announcement',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAnnouncement = async (announcementId: string) => {
|
||||||
|
try {
|
||||||
|
if (!confirm('Are you sure you want to delete this announcement?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/admin/announcements/${announcementId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Announcement deleted successfully',
|
||||||
|
});
|
||||||
|
fetchAnnouncements();
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Failed to delete announcement',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting announcement:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to delete announcement',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (announcement: Announcement) => {
|
||||||
|
setEditingAnnouncement(announcement);
|
||||||
|
setFormData({
|
||||||
|
title: announcement.title,
|
||||||
|
content: announcement.content,
|
||||||
|
priority: announcement.priority,
|
||||||
|
isActive: announcement.isActive,
|
||||||
|
expiresAt: announcement.expiresAt ? announcement.expiresAt.split('T')[0] : '',
|
||||||
|
});
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
priority: 'medium',
|
||||||
|
isActive: true,
|
||||||
|
expiresAt: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high':
|
||||||
|
return 'text-red-600 bg-red-50';
|
||||||
|
case 'medium':
|
||||||
|
return 'text-yellow-600 bg-yellow-50';
|
||||||
|
case 'low':
|
||||||
|
return 'text-green-600 bg-green-50';
|
||||||
|
default:
|
||||||
|
return 'text-gray-600 bg-gray-50';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityIcon = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high':
|
||||||
|
return <AlertCircle className='h-4 w-4' />;
|
||||||
|
case 'medium':
|
||||||
|
return <Clock className='h-4 w-4' />;
|
||||||
|
case 'low':
|
||||||
|
return <CheckCircle className='h-4 w-4' />;
|
||||||
|
default:
|
||||||
|
return <CheckCircle className='h-4 w-4' />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isExpired = (expiresAt?: string) => {
|
||||||
|
if (!expiresAt) return false;
|
||||||
|
return new Date(expiresAt) < new Date();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className='flex justify-center items-center py-8'>
|
||||||
|
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900'></div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{/* Header */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Megaphone className='h-6 w-6' />
|
||||||
|
<h2 className='text-2xl font-bold'>Announcement Management</h2>
|
||||||
|
</div>
|
||||||
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={() => resetForm()}>
|
||||||
|
<Plus className='h-4 w-4 mr-2' />
|
||||||
|
Create Announcement
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className='max-w-2xl'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New Announcement</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='title'>Title</Label>
|
||||||
|
<Input
|
||||||
|
id='title'
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
placeholder='Announcement title'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='content'>Content</Label>
|
||||||
|
<Textarea
|
||||||
|
id='content'
|
||||||
|
value={formData.content}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||||
|
setFormData({ ...formData, content: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='Announcement content'
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='priority'>Priority</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.priority}
|
||||||
|
onValueChange={(value: 'low' | 'medium' | 'high') =>
|
||||||
|
setFormData({ ...formData, priority: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select priority' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='low'>Low</SelectItem>
|
||||||
|
<SelectItem value='medium'>Medium</SelectItem>
|
||||||
|
<SelectItem value='high'>High</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='expiresAt'>Expires On (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id='expiresAt'
|
||||||
|
type='date'
|
||||||
|
value={formData.expiresAt}
|
||||||
|
onChange={(e) => setFormData({ ...formData, expiresAt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
id='isActive'
|
||||||
|
checked={formData.isActive}
|
||||||
|
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||||
|
className='rounded'
|
||||||
|
/>
|
||||||
|
<Label htmlFor='isActive'>Active (visible to users)</Label>
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-2 justify-end'>
|
||||||
|
<Button variant='outline' onClick={() => setIsCreateDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateAnnouncement}>Create Announcement</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Announcements Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>All Announcements ({announcements.length})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Title</TableHead>
|
||||||
|
<TableHead>Priority</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Expires</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{announcements.map((announcement) => (
|
||||||
|
<TableRow key={announcement.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div>
|
||||||
|
<div className='font-medium'>{announcement.title}</div>
|
||||||
|
<div className='text-sm text-gray-500 truncate max-w-xs'>
|
||||||
|
{announcement.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={getPriorityColor(announcement.priority)}>
|
||||||
|
<div className='flex items-center gap-1'>
|
||||||
|
{getPriorityIcon(announcement.priority)}
|
||||||
|
{announcement.priority}
|
||||||
|
</div>
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
isExpired(announcement.expiresAt)
|
||||||
|
? 'destructive'
|
||||||
|
: announcement.isActive
|
||||||
|
? 'default'
|
||||||
|
: 'secondary'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isExpired(announcement.expiresAt)
|
||||||
|
? 'Expired'
|
||||||
|
: announcement.isActive
|
||||||
|
? 'Active'
|
||||||
|
: 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Calendar className='h-4 w-4 text-gray-500' />
|
||||||
|
{announcement.expiresAt
|
||||||
|
? new Date(announcement.expiresAt).toLocaleDateString()
|
||||||
|
: 'Never'}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Calendar className='h-4 w-4 text-gray-500' />
|
||||||
|
{new Date(announcement.createdAt).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => openEditDialog(announcement)}
|
||||||
|
>
|
||||||
|
<Edit className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => handleDeleteAnnouncement(announcement.id)}
|
||||||
|
className='text-red-600 hover:text-red-700'
|
||||||
|
>
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{announcements.length === 0 && (
|
||||||
|
<div className='text-center py-8 text-gray-500'>
|
||||||
|
No announcements found. Create your first announcement!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Edit Announcement Dialog */}
|
||||||
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
|
<DialogContent className='max-w-2xl'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Announcement</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='edit-title'>Title</Label>
|
||||||
|
<Input
|
||||||
|
id='edit-title'
|
||||||
|
value={formData.title}
|
||||||
|
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||||
|
placeholder='Announcement title'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='edit-content'>Content</Label>
|
||||||
|
<Textarea
|
||||||
|
id='edit-content'
|
||||||
|
value={formData.content}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||||
|
setFormData({ ...formData, content: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder='Announcement content'
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='edit-priority'>Priority</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.priority}
|
||||||
|
onValueChange={(value: 'low' | 'medium' | 'high') =>
|
||||||
|
setFormData({ ...formData, priority: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select priority' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='low'>Low</SelectItem>
|
||||||
|
<SelectItem value='medium'>Medium</SelectItem>
|
||||||
|
<SelectItem value='high'>High</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='edit-expiresAt'>Expires On (Optional)</Label>
|
||||||
|
<Input
|
||||||
|
id='edit-expiresAt'
|
||||||
|
type='date'
|
||||||
|
value={formData.expiresAt}
|
||||||
|
onChange={(e) => setFormData({ ...formData, expiresAt: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
id='edit-isActive'
|
||||||
|
checked={formData.isActive}
|
||||||
|
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||||
|
className='rounded'
|
||||||
|
/>
|
||||||
|
<Label htmlFor='edit-isActive'>Active (visible to users)</Label>
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-2 justify-end'>
|
||||||
|
<Button variant='outline' onClick={() => setIsEditDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleEditAnnouncement}>Update Announcement</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { Plus, Edit, Trash2, MapPin, Settings, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Court {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CourtFormData {
|
||||||
|
name: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminCourtManagement() {
|
||||||
|
const [courts, setCourts] = useState<Court[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<string | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [editingCourt, setEditingCourt] = useState<Court | null>(null);
|
||||||
|
const [formData, setFormData] = useState<CourtFormData>({
|
||||||
|
name: '',
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCourts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchCourts = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/admin/courts');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCourts(data.courts || []);
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch courts',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching courts:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch courts',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Court name is required',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingCourt) {
|
||||||
|
setEditing(editingCourt.id);
|
||||||
|
const response = await fetch(`/api/admin/courts/${editingCourt.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Court updated successfully',
|
||||||
|
});
|
||||||
|
await fetchCourts();
|
||||||
|
resetForm();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.error || 'Failed to update court',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setCreating(true);
|
||||||
|
const response = await fetch('/api/admin/courts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Court created successfully',
|
||||||
|
});
|
||||||
|
await fetchCourts();
|
||||||
|
resetForm();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.error || 'Failed to create court',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving court:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to save court',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
setEditing(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (court: Court) => {
|
||||||
|
setEditingCourt(court);
|
||||||
|
setFormData({
|
||||||
|
name: court.name,
|
||||||
|
isActive: court.isActive,
|
||||||
|
});
|
||||||
|
setIsDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (courtId: string) => {
|
||||||
|
if (!confirm('Are you sure you want to delete this court? This action cannot be undone.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDeleting(courtId);
|
||||||
|
const response = await fetch(`/api/admin/courts/${courtId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Court deleted successfully',
|
||||||
|
});
|
||||||
|
await fetchCourts();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.error || 'Failed to delete court',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting court:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to delete court',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({ name: '', isActive: true });
|
||||||
|
setEditingCourt(null);
|
||||||
|
setIsDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Court Management</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className='animate-pulse border rounded-lg p-4'>
|
||||||
|
<div className='h-4 bg-gray-200 rounded w-3/4 mb-2'></div>
|
||||||
|
<div className='h-3 bg-gray-200 rounded w-1/2'></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='flex flex-row items-center justify-between'>
|
||||||
|
<CardTitle className='flex items-center gap-2'>
|
||||||
|
<Settings className='h-5 w-5' />
|
||||||
|
Court Management
|
||||||
|
</CardTitle>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button size='sm' variant='outline' onClick={fetchCourts}>
|
||||||
|
<RefreshCw className='h-4 w-4 mr-2' />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button size='sm' onClick={() => setEditingCourt(null)}>
|
||||||
|
<Plus className='h-4 w-4 mr-2' />
|
||||||
|
Add Court
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingCourt ? 'Edit Court' : 'Create New Court'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='name'>Court Name</Label>
|
||||||
|
<Input
|
||||||
|
id='name'
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder='e.g., Court 1, Main Court'
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<Switch
|
||||||
|
id='isActive'
|
||||||
|
checked={formData.isActive}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
setFormData({ ...formData, isActive: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor='isActive'>Active (available for booking)</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex justify-end space-x-2'>
|
||||||
|
<Button type='button' variant='outline' onClick={resetForm}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type='submit' disabled={creating || Boolean(editing)}>
|
||||||
|
{creating || editing ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className='h-4 w-4 mr-2 animate-spin' />
|
||||||
|
{editingCourt ? 'Updating...' : 'Creating...'}
|
||||||
|
</>
|
||||||
|
) : editingCourt ? (
|
||||||
|
'Update Court'
|
||||||
|
) : (
|
||||||
|
'Create Court'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{courts.length === 0 ? (
|
||||||
|
<div className='text-center py-8 text-gray-500'>
|
||||||
|
<MapPin className='h-12 w-12 mx-auto mb-4 text-gray-300' />
|
||||||
|
<p>No courts found. Create your first court to get started.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{courts.map((court) => (
|
||||||
|
<div key={court.id} className='border rounded-lg p-4'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<MapPin className='h-5 w-5 text-blue-600' />
|
||||||
|
<div>
|
||||||
|
<h3 className='font-medium'>{court.name}</h3>
|
||||||
|
<p className='text-sm text-gray-500'>
|
||||||
|
Created {new Date(court.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<Badge variant={court.isActive ? 'default' : 'secondary'}>
|
||||||
|
{court.isActive ? 'Active' : 'Inactive'}
|
||||||
|
</Badge>
|
||||||
|
|
||||||
|
<div className='flex gap-1'>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outline'
|
||||||
|
onClick={() => handleEdit(court)}
|
||||||
|
disabled={editing === court.id}
|
||||||
|
>
|
||||||
|
<Edit className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outline'
|
||||||
|
onClick={() => handleDelete(court.id)}
|
||||||
|
disabled={deleting === court.id}
|
||||||
|
className='text-red-600 hover:text-red-700'
|
||||||
|
>
|
||||||
|
{deleting === court.id ? (
|
||||||
|
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RefreshCw, User, Clock, Globe } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface ActivityLog {
|
||||||
|
id: string;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId?: string;
|
||||||
|
details?: string;
|
||||||
|
ipAddress?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
surname: string;
|
||||||
|
email: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminLogs() {
|
||||||
|
const [logs, setLogs] = useState<ActivityLog[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchLogs();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/admin/logs?limit=50');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setLogs(data.logs || []);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch logs');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching logs:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionBadgeColor = (action: string) => {
|
||||||
|
switch (action.toLowerCase()) {
|
||||||
|
case 'create':
|
||||||
|
case 'created':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'update':
|
||||||
|
case 'updated':
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
case 'delete':
|
||||||
|
case 'deleted':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
case 'login':
|
||||||
|
return 'bg-purple-100 text-purple-800';
|
||||||
|
case 'logout':
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatUserAgent = (userAgent?: string) => {
|
||||||
|
if (!userAgent) return 'Unknown';
|
||||||
|
if (userAgent.includes('Chrome')) return 'Chrome';
|
||||||
|
if (userAgent.includes('Firefox')) return 'Firefox';
|
||||||
|
if (userAgent.includes('Safari')) return 'Safari';
|
||||||
|
if (userAgent.includes('Edge')) return 'Edge';
|
||||||
|
return 'Unknown Browser';
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='flex flex-row items-center justify-between'>
|
||||||
|
<CardTitle>Activity Logs</CardTitle>
|
||||||
|
<Button size='sm' disabled>
|
||||||
|
<RefreshCw className='h-4 w-4 animate-spin mr-2' />
|
||||||
|
Loading...
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<div key={i} className='animate-pulse border rounded-lg p-4'>
|
||||||
|
<div className='h-4 bg-gray-200 rounded w-3/4 mb-2'></div>
|
||||||
|
<div className='h-3 bg-gray-200 rounded w-1/2'></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='flex flex-row items-center justify-between'>
|
||||||
|
<CardTitle>Activity Logs</CardTitle>
|
||||||
|
<Button size='sm' onClick={fetchLogs}>
|
||||||
|
<RefreshCw className='h-4 w-4 mr-2' />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{logs.length === 0 ? (
|
||||||
|
<div className='text-center py-8 text-gray-500'>No activity logs found.</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-4 max-h-96 overflow-y-auto'>
|
||||||
|
{logs.map((log) => (
|
||||||
|
<div key={log.id} className='border rounded-lg p-4 space-y-3'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-3'>
|
||||||
|
<Badge className={getActionBadgeColor(log.action)}>{log.action}</Badge>
|
||||||
|
<span className='text-sm font-medium'>
|
||||||
|
{log.entityType}
|
||||||
|
{log.entityId && (
|
||||||
|
<span className='text-gray-500 ml-1'>
|
||||||
|
({log.entityId.substring(0, 8)}...)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-1 text-xs text-gray-500'>
|
||||||
|
<Clock className='h-3 w-3' />
|
||||||
|
{format(new Date(log.createdAt), 'MMM dd, HH:mm')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between text-sm'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<User className='h-4 w-4 text-gray-400' />
|
||||||
|
{log.user ? (
|
||||||
|
<span>
|
||||||
|
{log.user.name} {log.user.surname} ({log.user.email})
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className='text-gray-500'>System/Anonymous</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{log.ipAddress && (
|
||||||
|
<div className='flex items-center gap-2 text-xs text-gray-500'>
|
||||||
|
<Globe className='h-3 w-3' />
|
||||||
|
<span>{log.ipAddress}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{formatUserAgent(log.userAgent)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{log.details && (
|
||||||
|
<div className='text-xs text-gray-600 bg-gray-50 rounded p-2'>
|
||||||
|
<pre className='whitespace-pre-wrap break-words'>
|
||||||
|
{JSON.stringify(JSON.parse(log.details), null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { RefreshCw, Calendar, Clock, MapPin } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface Booking {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
status: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
court: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
surname: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminRecentBookings() {
|
||||||
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBookings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchBookings = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/admin/recent-bookings?limit=5');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setBookings(data.bookings || []);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to fetch bookings');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching bookings:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return format(date, 'MMM dd');
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusBadgeColor = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'active':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='flex flex-row items-center justify-between'>
|
||||||
|
<CardTitle>Recent Bookings</CardTitle>
|
||||||
|
<Button size='sm' disabled>
|
||||||
|
<RefreshCw className='h-4 w-4 animate-spin mr-2' />
|
||||||
|
Loading...
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className='animate-pulse border rounded-lg p-4'>
|
||||||
|
<div className='h-4 bg-gray-200 rounded w-3/4 mb-2'></div>
|
||||||
|
<div className='h-3 bg-gray-200 rounded w-1/2'></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='flex flex-row items-center justify-between'>
|
||||||
|
<CardTitle>Recent Bookings</CardTitle>
|
||||||
|
<Button size='sm' onClick={fetchBookings}>
|
||||||
|
<RefreshCw className='h-4 w-4 mr-2' />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{bookings.length === 0 ? (
|
||||||
|
<div className='text-center py-6 text-gray-500'>No recent bookings found.</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{bookings.map((booking) => (
|
||||||
|
<div key={booking.id} className='border rounded-lg p-4 space-y-3'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<p className='font-medium'>
|
||||||
|
{booking.user.name} {booking.user.surname}
|
||||||
|
</p>
|
||||||
|
<p className='text-sm text-gray-500'>{booking.user.email}</p>
|
||||||
|
</div>
|
||||||
|
<Badge className={getStatusBadgeColor(booking.status)}>{booking.status}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center gap-4 text-sm text-gray-600'>
|
||||||
|
<div className='flex items-center gap-1'>
|
||||||
|
<MapPin className='h-4 w-4' />
|
||||||
|
<span>{booking.court.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-1'>
|
||||||
|
<Calendar className='h-4 w-4' />
|
||||||
|
<span>{formatDate(booking.date)}</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-1'>
|
||||||
|
<Clock className='h-4 w-4' />
|
||||||
|
<span>
|
||||||
|
{booking.startTime} - {booking.endTime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{booking.notes && (
|
||||||
|
<p className='text-sm text-gray-600 bg-gray-50 rounded p-2'>{booking.notes}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { toast } from '@/hooks/use-toast';
|
||||||
|
import { Settings, Save, RefreshCw } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Setting {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SettingsData {
|
||||||
|
booking_window_days: string;
|
||||||
|
max_booking_duration_hours: string;
|
||||||
|
min_booking_duration_minutes: string;
|
||||||
|
booking_start_time: string;
|
||||||
|
booking_end_time: string;
|
||||||
|
allow_weekend_bookings: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminSettingsManagement() {
|
||||||
|
const [settings, setSettings] = useState<SettingsData>({
|
||||||
|
booking_window_days: '7',
|
||||||
|
max_booking_duration_hours: '2',
|
||||||
|
min_booking_duration_minutes: '30',
|
||||||
|
booking_start_time: '08:00',
|
||||||
|
booking_end_time: '22:00',
|
||||||
|
allow_weekend_bookings: 'true',
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/admin/settings');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const settingsMap: SettingsData = {
|
||||||
|
booking_window_days: '7',
|
||||||
|
max_booking_duration_hours: '2',
|
||||||
|
min_booking_duration_minutes: '30',
|
||||||
|
booking_start_time: '08:00',
|
||||||
|
booking_end_time: '22:00',
|
||||||
|
allow_weekend_bookings: 'true',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map the settings array to our object
|
||||||
|
data.settings?.forEach((setting: Setting) => {
|
||||||
|
if (setting.key in settingsMap) {
|
||||||
|
settingsMap[setting.key as keyof SettingsData] = setting.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSettings(settingsMap);
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch settings',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching settings:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch settings',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
// Convert settings object to array format
|
||||||
|
const settingsArray = Object.entries(settings).map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await fetch('/api/admin/settings', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ settings: settingsArray }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Settings updated successfully',
|
||||||
|
});
|
||||||
|
await fetchSettings();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: error.error || 'Failed to update settings',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating settings:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to update settings',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateSetting = (key: keyof SettingsData, value: string) => {
|
||||||
|
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2'>
|
||||||
|
<Settings className='h-5 w-5' />
|
||||||
|
System Settings
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className='animate-pulse'>
|
||||||
|
<div className='h-4 bg-gray-200 rounded w-1/4 mb-2'></div>
|
||||||
|
<div className='h-10 bg-gray-200 rounded w-1/2'></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='flex flex-row items-center justify-between'>
|
||||||
|
<CardTitle className='flex items-center gap-2'>
|
||||||
|
<Settings className='h-5 w-5' />
|
||||||
|
System Settings
|
||||||
|
</CardTitle>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button size='sm' variant='outline' onClick={fetchSettings}>
|
||||||
|
<RefreshCw className='h-4 w-4 mr-2' />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
<Button size='sm' onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw className='h-4 w-4 mr-2 animate-spin' />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className='h-4 w-4 mr-2' />
|
||||||
|
Save Changes
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-6'>
|
||||||
|
<div className='grid grid-cols-1 md:grid-cols-2 gap-6'>
|
||||||
|
{/* Booking Window */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='booking_window_days'>Booking Window (days)</Label>
|
||||||
|
<Input
|
||||||
|
id='booking_window_days'
|
||||||
|
type='number'
|
||||||
|
min='1'
|
||||||
|
max='30'
|
||||||
|
value={settings.booking_window_days}
|
||||||
|
onChange={(e) => updateSetting('booking_window_days', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className='text-sm text-gray-500'>How many days in advance users can book</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max Duration */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='max_booking_duration_hours'>Max Booking Duration (hours)</Label>
|
||||||
|
<Input
|
||||||
|
id='max_booking_duration_hours'
|
||||||
|
type='number'
|
||||||
|
min='0.5'
|
||||||
|
max='8'
|
||||||
|
step='0.5'
|
||||||
|
value={settings.max_booking_duration_hours}
|
||||||
|
onChange={(e) => updateSetting('max_booking_duration_hours', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className='text-sm text-gray-500'>Maximum hours per booking session</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Min Duration */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='min_booking_duration_minutes'>Min Booking Duration (minutes)</Label>
|
||||||
|
<Input
|
||||||
|
id='min_booking_duration_minutes'
|
||||||
|
type='number'
|
||||||
|
min='15'
|
||||||
|
max='120'
|
||||||
|
step='15'
|
||||||
|
value={settings.min_booking_duration_minutes}
|
||||||
|
onChange={(e) => updateSetting('min_booking_duration_minutes', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className='text-sm text-gray-500'>Minimum minutes per booking session</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Start Time */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='booking_start_time'>Daily Start Time</Label>
|
||||||
|
<Input
|
||||||
|
id='booking_start_time'
|
||||||
|
type='time'
|
||||||
|
value={settings.booking_start_time}
|
||||||
|
onChange={(e) => updateSetting('booking_start_time', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className='text-sm text-gray-500'>When courts open for booking each day</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End Time */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='booking_end_time'>Daily End Time</Label>
|
||||||
|
<Input
|
||||||
|
id='booking_end_time'
|
||||||
|
type='time'
|
||||||
|
value={settings.booking_end_time}
|
||||||
|
onChange={(e) => updateSetting('booking_end_time', e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className='text-sm text-gray-500'>When courts close for booking each day</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weekend Bookings */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<Switch
|
||||||
|
id='allow_weekend_bookings'
|
||||||
|
checked={settings.allow_weekend_bookings === 'true'}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
updateSetting('allow_weekend_bookings', checked.toString())
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor='allow_weekend_bookings'>Allow Weekend Bookings</Label>
|
||||||
|
</div>
|
||||||
|
<p className='text-sm text-gray-500'>Whether users can book courts on weekends</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='border-t pt-6'>
|
||||||
|
<h3 className='text-lg font-medium mb-4'>Current Configuration Summary</h3>
|
||||||
|
<div className='grid grid-cols-1 md:grid-cols-2 gap-4 text-sm'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<p>
|
||||||
|
<strong>Booking Window:</strong> {settings.booking_window_days} days
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Session Duration:</strong> {settings.min_booking_duration_minutes}min -{' '}
|
||||||
|
{settings.max_booking_duration_hours}hrs
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Operating Hours:</strong> {settings.booking_start_time} -{' '}
|
||||||
|
{settings.booking_end_time}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<p>
|
||||||
|
<strong>Weekend Bookings:</strong>{' '}
|
||||||
|
{settings.allow_weekend_bookings === 'true' ? 'Enabled' : 'Disabled'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { UserPlus, Edit, Trash2, Search, Users, Mail, Calendar } from 'lucide-react';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
surname: string;
|
||||||
|
email: string;
|
||||||
|
role: 'user' | 'admin';
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserFormData {
|
||||||
|
name: string;
|
||||||
|
surname: string;
|
||||||
|
email: string;
|
||||||
|
role: 'user' | 'admin';
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminUserManagement() {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [formData, setFormData] = useState<UserFormData>({
|
||||||
|
name: '',
|
||||||
|
surname: '',
|
||||||
|
email: '',
|
||||||
|
role: 'user',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/admin/users');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setUsers(data.users);
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to fetch users');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching users:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch users',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateUser = async () => {
|
||||||
|
try {
|
||||||
|
if (!formData.name || !formData.surname || !formData.email || !formData.password) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Please fill in all required fields',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'User created successfully',
|
||||||
|
});
|
||||||
|
setIsCreateDialogOpen(false);
|
||||||
|
resetForm();
|
||||||
|
fetchUsers();
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Failed to create user',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating user:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to create user',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditUser = async () => {
|
||||||
|
try {
|
||||||
|
if (!editingUser || !formData.name || !formData.surname || !formData.email) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Please fill in all required fields',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = { ...formData };
|
||||||
|
if (!updateData.password) {
|
||||||
|
delete updateData.password; // Don't update password if not provided
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/admin/users/${editingUser.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(updateData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'User updated successfully',
|
||||||
|
});
|
||||||
|
setIsEditDialogOpen(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
resetForm();
|
||||||
|
fetchUsers();
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Failed to update user',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to update user',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/admin/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'User deleted successfully',
|
||||||
|
});
|
||||||
|
fetchUsers();
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Failed to delete user',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting user:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to delete user',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (user: User) => {
|
||||||
|
setEditingUser(user);
|
||||||
|
setFormData({
|
||||||
|
name: user.name,
|
||||||
|
surname: user.surname,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
password: '', // Don't pre-fill password
|
||||||
|
});
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
surname: '',
|
||||||
|
email: '',
|
||||||
|
role: 'user',
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredUsers = users.filter(
|
||||||
|
(user) =>
|
||||||
|
user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
user.surname.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
user.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className='flex justify-center items-center py-8'>
|
||||||
|
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900'></div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{/* Header */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Users className='h-6 w-6' />
|
||||||
|
<h2 className='text-2xl font-bold'>User Management</h2>
|
||||||
|
</div>
|
||||||
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button onClick={() => resetForm()}>
|
||||||
|
<UserPlus className='h-4 w-4 mr-2' />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create New User</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='name'>First Name</Label>
|
||||||
|
<Input
|
||||||
|
id='name'
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder='John'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='surname'>Last Name</Label>
|
||||||
|
<Input
|
||||||
|
id='surname'
|
||||||
|
value={formData.surname}
|
||||||
|
onChange={(e) => setFormData({ ...formData, surname: e.target.value })}
|
||||||
|
placeholder='Doe'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='email'>Email</Label>
|
||||||
|
<Input
|
||||||
|
id='email'
|
||||||
|
type='email'
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
placeholder='john.doe@example.com'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='password'>Password</Label>
|
||||||
|
<Input
|
||||||
|
id='password'
|
||||||
|
type='password'
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
placeholder='Enter password'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='role'>Role</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.role}
|
||||||
|
onValueChange={(value: 'user' | 'admin') =>
|
||||||
|
setFormData({ ...formData, role: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select role' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='user'>User</SelectItem>
|
||||||
|
<SelectItem value='admin'>Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-2 justify-end'>
|
||||||
|
<Button variant='outline' onClick={() => setIsCreateDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleCreateUser}>Create User</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Search className='h-4 w-4 text-gray-500' />
|
||||||
|
<Input
|
||||||
|
placeholder='Search users by name or email...'
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
className='max-w-sm'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Users Table */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>All Users ({filteredUsers.length})</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Email</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Created</TableHead>
|
||||||
|
<TableHead>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredUsers.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className='font-medium'>
|
||||||
|
{user.name} {user.surname}
|
||||||
|
</TableCell>
|
||||||
|
<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()}
|
||||||
|
</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={() => handleDeleteUser(user.id)}
|
||||||
|
className='text-red-600 hover:text-red-700'
|
||||||
|
>
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
{filteredUsers.length === 0 && (
|
||||||
|
<div className='text-center py-8 text-gray-500'>
|
||||||
|
No users found matching your search criteria
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Edit User Dialog */}
|
||||||
|
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit User</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='edit-name'>First Name</Label>
|
||||||
|
<Input
|
||||||
|
id='edit-name'
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder='John'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='edit-surname'>Last Name</Label>
|
||||||
|
<Input
|
||||||
|
id='edit-surname'
|
||||||
|
value={formData.surname}
|
||||||
|
onChange={(e) => setFormData({ ...formData, surname: e.target.value })}
|
||||||
|
placeholder='Doe'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='edit-email'>Email</Label>
|
||||||
|
<Input
|
||||||
|
id='edit-email'
|
||||||
|
type='email'
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
placeholder='john.doe@example.com'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='edit-password'>New Password (leave blank to keep current)</Label>
|
||||||
|
<Input
|
||||||
|
id='edit-password'
|
||||||
|
type='password'
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
placeholder='Enter new password'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor='edit-role'>Role</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.role}
|
||||||
|
onValueChange={(value: 'user' | 'admin') => setFormData({ ...formData, role: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select role' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='user'>User</SelectItem>
|
||||||
|
<SelectItem value='admin'>Admin</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className='flex gap-2 justify-end'>
|
||||||
|
<Button variant='outline' onClick={() => setIsEditDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleEditUser}>Update User</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Users, Calendar, Settings, BarChart3, Bell, Shield, Clock, MapPin, Activity, LogOut } from 'lucide-react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { AdminUserManagement } from './AdminUserManagement';
|
||||||
|
import { AdminAnnouncementManagement } from './AdminAnnouncementManagement';
|
||||||
|
import { AdminLogs } from './AdminLogs';
|
||||||
|
import { AdminRecentBookings } from './AdminRecentBookings';
|
||||||
|
import { AdminCourtManagement } from './AdminCourtManagement';
|
||||||
|
import { AdminSettingsManagement } from './AdminSettingsManagement';
|
||||||
|
|
||||||
|
export function AdminDashboard() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [stats] = useState({
|
||||||
|
totalUsers: 125,
|
||||||
|
todayBookings: 18,
|
||||||
|
totalCourts: 2,
|
||||||
|
weeklyRevenue: 850,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
router.push('/');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='min-h-screen bg-gray-50'>
|
||||||
|
{/* Header */}
|
||||||
|
<header className='bg-white border-b border-gray-200'>
|
||||||
|
<div className='container mx-auto px-4'>
|
||||||
|
<div className='flex items-center justify-between h-16'>
|
||||||
|
<div className='flex items-center space-x-4'>
|
||||||
|
<Shield className='h-6 w-6 text-blue-600' />
|
||||||
|
<h1 className='text-xl font-semibold text-gray-900'>Admin Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center space-x-4'>
|
||||||
|
<Badge variant='secondary' className='bg-blue-100 text-blue-800'>
|
||||||
|
Administrator
|
||||||
|
</Badge>
|
||||||
|
<Button variant='ghost' size='sm' onClick={handleLogout}>
|
||||||
|
<LogOut className='h-4 w-4' />
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className='container mx-auto px-4 py-8'>
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||||
|
<CardTitle className='text-sm font-medium'>Total Users</CardTitle>
|
||||||
|
<Users className='h-4 w-4 text-muted-foreground' />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='text-2xl font-bold'>{stats.totalUsers}</div>
|
||||||
|
<p className='text-xs text-muted-foreground'>+12% from last month</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||||
|
<CardTitle className='text-sm font-medium'>Today's Bookings</CardTitle>
|
||||||
|
<Calendar className='h-4 w-4 text-muted-foreground' />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='text-2xl font-bold'>{stats.todayBookings}</div>
|
||||||
|
<p className='text-xs text-muted-foreground'>+5% from yesterday</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||||
|
<CardTitle className='text-sm font-medium'>Active Courts</CardTitle>
|
||||||
|
<MapPin className='h-4 w-4 text-muted-foreground' />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='text-2xl font-bold'>{stats.totalCourts}</div>
|
||||||
|
<p className='text-xs text-muted-foreground'>All courts operational</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='flex flex-row items-center justify-between space-y-0 pb-2'>
|
||||||
|
<CardTitle className='text-sm font-medium'>Weekly Revenue</CardTitle>
|
||||||
|
<BarChart3 className='h-4 w-4 text-muted-foreground' />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='text-2xl font-bold'>${stats.weeklyRevenue}</div>
|
||||||
|
<p className='text-xs text-muted-foreground'>+8% from last week</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin Tabs */}
|
||||||
|
<Tabs defaultValue='bookings' className='space-y-6'>
|
||||||
|
<TabsList className='grid w-full grid-cols-6'>
|
||||||
|
<TabsTrigger value='bookings'>Bookings</TabsTrigger>
|
||||||
|
<TabsTrigger value='users'>Users</TabsTrigger>
|
||||||
|
<TabsTrigger value='courts'>Courts</TabsTrigger>
|
||||||
|
<TabsTrigger value='settings'>Settings</TabsTrigger>
|
||||||
|
<TabsTrigger value='announcements'>Announcements</TabsTrigger>
|
||||||
|
<TabsTrigger value='logs'>Logs</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value='bookings'>
|
||||||
|
<AdminRecentBookings />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value='users'>
|
||||||
|
<AdminUserManagement />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value='courts'>
|
||||||
|
<AdminCourtManagement />
|
||||||
|
</TabsContent>{' '}
|
||||||
|
<TabsContent value='settings'>
|
||||||
|
<AdminSettingsManagement />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value='announcements'>
|
||||||
|
<AdminAnnouncementManagement />
|
||||||
|
</TabsContent>{' '}
|
||||||
|
<TabsContent value='logs'>
|
||||||
|
<AdminLogs />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Bell, Info, AlertTriangle, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Announcement {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
priority: 'low' | 'medium' | 'high';
|
||||||
|
isActive: boolean;
|
||||||
|
expiresAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnnouncementsList() {
|
||||||
|
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAnnouncements();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchAnnouncements = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/admin/announcements');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
// Filter to show only active, non-expired announcements
|
||||||
|
const activeAnnouncements = data.announcements.filter((announcement: Announcement) => {
|
||||||
|
if (!announcement.isActive) return false;
|
||||||
|
if (announcement.expiresAt && new Date(announcement.expiresAt) < new Date()) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
setAnnouncements(activeAnnouncements);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching announcements:', error);
|
||||||
|
// Fallback to default announcements if API fails
|
||||||
|
setAnnouncements([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'Welcome to Table Tennis Booking!',
|
||||||
|
content:
|
||||||
|
'Book your favorite court slots up to 7 days in advance. Remember to arrive 5 minutes early for your booking.',
|
||||||
|
priority: 'medium',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityIcon = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high':
|
||||||
|
return <AlertCircle className='h-4 w-4 text-red-500' />;
|
||||||
|
case 'medium':
|
||||||
|
return <AlertTriangle className='h-4 w-4 text-yellow-500' />;
|
||||||
|
default:
|
||||||
|
return <Info className='h-4 w-4 text-blue-500' />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high':
|
||||||
|
return 'bg-red-100 text-red-800 border-red-200';
|
||||||
|
case 'medium':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||||
|
default:
|
||||||
|
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2'>
|
||||||
|
<Bell className='h-5 w-5' />
|
||||||
|
Announcements
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{announcements
|
||||||
|
.filter((a) => a.isActive)
|
||||||
|
.map((announcement) => (
|
||||||
|
<div key={announcement.id} className='p-4 border rounded-lg bg-gray-50'>
|
||||||
|
<div className='flex items-start justify-between gap-3'>
|
||||||
|
<div className='flex items-start gap-2 flex-1'>
|
||||||
|
{getPriorityIcon(announcement.priority)}
|
||||||
|
<div className='space-y-1'>
|
||||||
|
<h4 className='font-medium text-sm'>{announcement.title}</h4>
|
||||||
|
<p className='text-sm text-gray-600'>{announcement.content}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant='outline'
|
||||||
|
className={`text-xs ${getPriorityColor(announcement.priority)}`}
|
||||||
|
>
|
||||||
|
{announcement.priority}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{announcements.filter((a) => a.isActive).length === 0 && (
|
||||||
|
<div className='text-center py-8 text-gray-500'>
|
||||||
|
<Bell className='h-8 w-8 mx-auto mb-2 opacity-30' />
|
||||||
|
<p>No announcements at this time</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Please enter a valid email address'),
|
||||||
|
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type LoginForm = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<LoginForm>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: LoginForm) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.error || 'Login failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Welcome back!',
|
||||||
|
description: "You've been successfully logged in.",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect based on user role
|
||||||
|
if (result.user.role === 'admin') {
|
||||||
|
router.push('/admin');
|
||||||
|
} else {
|
||||||
|
router.push('/dashboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the page to update auth state
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Login failed',
|
||||||
|
description: error instanceof Error ? error.message : 'An unexpected error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='w-full max-w-md mx-auto'>
|
||||||
|
<CardHeader className='space-y-1'>
|
||||||
|
<CardTitle className='text-2xl text-center'>Welcome back</CardTitle>
|
||||||
|
<CardDescription className='text-center'>Enter your credentials to access your account</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='email'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder='Enter your email'
|
||||||
|
type='email'
|
||||||
|
autoComplete='email'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='password'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder='Enter your password'
|
||||||
|
type='password'
|
||||||
|
autoComplete='current-password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type='submit' className='w-full' disabled={isLoading}>
|
||||||
|
{isLoading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
const registerSchema = z
|
||||||
|
.object({
|
||||||
|
email: z.string().email('Please enter a valid email address'),
|
||||||
|
name: z.string().min(2, 'Name must be at least 2 characters'),
|
||||||
|
surname: z.string().min(2, 'Surname must be at least 2 characters'),
|
||||||
|
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: "Passwords don't match",
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
type RegisterForm = z.infer<typeof registerSchema>;
|
||||||
|
|
||||||
|
export function RegisterForm() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const form = useForm<RegisterForm>({
|
||||||
|
resolver: zodResolver(registerSchema),
|
||||||
|
defaultValues: {
|
||||||
|
email: '',
|
||||||
|
name: '',
|
||||||
|
surname: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: RegisterForm) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: data.email,
|
||||||
|
name: data.name,
|
||||||
|
surname: data.surname,
|
||||||
|
password: data.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.error || 'Registration failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Account created!',
|
||||||
|
description: 'Welcome to the table tennis booking system.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Redirect to dashboard after successful registration
|
||||||
|
router.push('/dashboard');
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Registration failed',
|
||||||
|
description: error instanceof Error ? error.message : 'An unexpected error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='w-full max-w-md mx-auto'>
|
||||||
|
<CardHeader className='space-y-1'>
|
||||||
|
<CardTitle className='text-2xl text-center'>Create an account</CardTitle>
|
||||||
|
<CardDescription className='text-center'>Enter your details to create your account</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='name'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>First Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder='John' autoComplete='given-name' {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='surname'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Last Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder='Doe' autoComplete='family-name' {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='email'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Email</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder='john@example.com'
|
||||||
|
type='email'
|
||||||
|
autoComplete='email'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='password'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder='Create a password'
|
||||||
|
type='password'
|
||||||
|
autoComplete='new-password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name='confirmPassword'
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Confirm Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder='Confirm your password'
|
||||||
|
type='password'
|
||||||
|
autoComplete='new-password'
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button type='submit' className='w-full' disabled={isLoading}>
|
||||||
|
{isLoading ? 'Creating account...' : 'Create account'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Logged in successfully',
|
||||||
|
});
|
||||||
|
if (data.user.role === 'admin') {
|
||||||
|
router.push('/admin');
|
||||||
|
} else {
|
||||||
|
router.push('/dashboard');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Login failed',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An unexpected error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='w-full max-w-md mx-auto'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Sign In</CardTitle>
|
||||||
|
<CardDescription>Enter your email and password to access your account</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='email'>Email</Label>
|
||||||
|
<Input
|
||||||
|
id='email'
|
||||||
|
type='email'
|
||||||
|
placeholder='Enter your email'
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='password'>Password</Label>
|
||||||
|
<Input
|
||||||
|
id='password'
|
||||||
|
type='password'
|
||||||
|
placeholder='Enter your password'
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type='submit' className='w-full' disabled={isLoading}>
|
||||||
|
{isLoading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<div className='mt-4 text-center'>
|
||||||
|
<Button variant='link' onClick={() => router.push('/register')} className='text-sm'>
|
||||||
|
Don't have an account? Sign up
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
|
||||||
|
export function RegisterForm() {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
email: '',
|
||||||
|
name: '',
|
||||||
|
surname: '',
|
||||||
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
});
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (formData.password !== formData.confirmPassword) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Passwords do not match',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: formData.email,
|
||||||
|
name: formData.name,
|
||||||
|
surname: formData.surname,
|
||||||
|
password: formData.password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Account created successfully! Please log in.',
|
||||||
|
});
|
||||||
|
router.push('/');
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Registration failed',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'An unexpected error occurred',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (field: string, value: string) => {
|
||||||
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className='w-full max-w-md mx-auto'>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Create Account</CardTitle>
|
||||||
|
<CardDescription>Fill in your details to create a new account</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSubmit} className='space-y-4'>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='name'>First Name</Label>
|
||||||
|
<Input
|
||||||
|
id='name'
|
||||||
|
type='text'
|
||||||
|
placeholder='John'
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => handleInputChange('name', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='surname'>Last Name</Label>
|
||||||
|
<Input
|
||||||
|
id='surname'
|
||||||
|
type='text'
|
||||||
|
placeholder='Doe'
|
||||||
|
value={formData.surname}
|
||||||
|
onChange={(e) => handleInputChange('surname', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='email'>Email</Label>
|
||||||
|
<Input
|
||||||
|
id='email'
|
||||||
|
type='email'
|
||||||
|
placeholder='john.doe@example.com'
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => handleInputChange('email', e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='password'>Password</Label>
|
||||||
|
<Input
|
||||||
|
id='password'
|
||||||
|
type='password'
|
||||||
|
placeholder='Enter your password'
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => handleInputChange('password', e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='confirmPassword'>Confirm Password</Label>
|
||||||
|
<Input
|
||||||
|
id='confirmPassword'
|
||||||
|
type='password'
|
||||||
|
placeholder='Confirm your password'
|
||||||
|
value={formData.confirmPassword}
|
||||||
|
onChange={(e) => handleInputChange('confirmPassword', e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type='submit' className='w-full' disabled={isLoading}>
|
||||||
|
{isLoading ? 'Creating account...' : 'Create Account'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<div className='mt-4 text-center'>
|
||||||
|
<Button variant='link' onClick={() => router.push('/')} className='text-sm'>
|
||||||
|
Already have an account? Sign in
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Calendar, Clock, MapPin, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
interface Court {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Booking {
|
||||||
|
id: string;
|
||||||
|
courtId: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
status: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookingSlot {
|
||||||
|
time: string;
|
||||||
|
courtId: string;
|
||||||
|
courtName: string;
|
||||||
|
available: boolean;
|
||||||
|
bookingId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BookingCalendar() {
|
||||||
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||||
|
const [courts, setCourts] = useState<Court[]>([]);
|
||||||
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
|
const [bookingSlots, setBookingSlots] = useState<BookingSlot[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// Time slots for booking (7 PM to 11 PM)
|
||||||
|
const timeSlots = ['19:00', '20:00', '21:00', '22:00'];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCourts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (courts.length > 0) {
|
||||||
|
fetchBookings();
|
||||||
|
}
|
||||||
|
}, [selectedDate, courts]);
|
||||||
|
|
||||||
|
const fetchCourts = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/courts');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCourts(data.courts.filter((court: Court) => court.isActive));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching courts:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch courts',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchBookings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/bookings');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setBookings(data.bookings);
|
||||||
|
generateBookingSlots(data.bookings);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching bookings:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch bookings',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateBookingSlots = (existingBookings: Booking[]) => {
|
||||||
|
const dateStr = selectedDate.toISOString().split('T')[0];
|
||||||
|
const slots: BookingSlot[] = [];
|
||||||
|
|
||||||
|
courts.forEach((court) => {
|
||||||
|
timeSlots.forEach((time) => {
|
||||||
|
const existingBooking = existingBookings.find(
|
||||||
|
(booking) =>
|
||||||
|
booking.courtId === court.id &&
|
||||||
|
booking.date === dateStr &&
|
||||||
|
booking.startTime === time &&
|
||||||
|
booking.status === 'active'
|
||||||
|
);
|
||||||
|
|
||||||
|
slots.push({
|
||||||
|
time,
|
||||||
|
courtId: court.id,
|
||||||
|
courtName: court.name,
|
||||||
|
available: !existingBooking,
|
||||||
|
bookingId: existingBooking?.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setBookingSlots(slots);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBookSlot = async (courtId: string, timeSlot: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const dateStr = selectedDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const response = await fetch('/api/bookings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
courtId,
|
||||||
|
date: dateStr,
|
||||||
|
timeSlot,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Booking created successfully!',
|
||||||
|
});
|
||||||
|
fetchBookings(); // Refresh bookings
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Failed to create booking',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error booking slot:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to create booking',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateDate = (direction: 'prev' | 'next') => {
|
||||||
|
const newDate = new Date(selectedDate);
|
||||||
|
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
|
||||||
|
setSelectedDate(newDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isToday = (date: Date) => {
|
||||||
|
const today = new Date();
|
||||||
|
return date.toDateString() === today.toDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPastDate = (date: Date) => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return date < today;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2'>
|
||||||
|
<Calendar className='h-5 w-5' />
|
||||||
|
Book Your Court
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{/* Date Navigation */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => navigateDate('prev')}
|
||||||
|
disabled={isPastDate(new Date(selectedDate.getTime() - 24 * 60 * 60 * 1000))}
|
||||||
|
>
|
||||||
|
<ChevronLeft className='h-4 w-4' />
|
||||||
|
Previous Day
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<h3 className='text-lg font-semibold'>
|
||||||
|
{selectedDate.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
{isToday(selectedDate) && <span className='text-sm text-blue-600 ml-2'>(Today)</span>}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<Button variant='outline' size='sm' onClick={() => navigateDate('next')}>
|
||||||
|
Next Day
|
||||||
|
<ChevronRight className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<div className='text-center py-8'>
|
||||||
|
<div className='inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900'></div>
|
||||||
|
<p className='mt-2 text-sm text-gray-500'>Loading booking slots...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No Courts Available */}
|
||||||
|
{!loading && courts.length === 0 && (
|
||||||
|
<div className='text-center py-8'>
|
||||||
|
<p className='text-gray-500'>No courts available for booking</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Past Date Warning */}
|
||||||
|
{isPastDate(selectedDate) && (
|
||||||
|
<div className='bg-yellow-50 border border-yellow-200 rounded-lg p-4'>
|
||||||
|
<p className='text-yellow-800 text-sm'>
|
||||||
|
You cannot book courts for past dates. Please select a current or future date.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time Slots Grid */}
|
||||||
|
{!loading && courts.length > 0 && !isPastDate(selectedDate) && (
|
||||||
|
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
|
||||||
|
{bookingSlots.map((slot, index) => (
|
||||||
|
<div
|
||||||
|
key={`${slot.courtId}-${slot.time}`}
|
||||||
|
className={`p-4 border rounded-lg transition-colors ${
|
||||||
|
slot.available
|
||||||
|
? 'border-green-200 bg-green-50 hover:bg-green-100'
|
||||||
|
: 'border-red-200 bg-red-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<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' />
|
||||||
|
{slot.time} -{' '}
|
||||||
|
{String(parseInt(slot.time.split(':')[0]) + 1).padStart(2, '0')}:00
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2 text-sm text-gray-600'>
|
||||||
|
<MapPin className='h-4 w-4' />
|
||||||
|
{slot.courtName}
|
||||||
|
</div>
|
||||||
|
{!slot.available && (
|
||||||
|
<div className='text-xs text-red-600'>Already booked</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
disabled={!slot.available || loading}
|
||||||
|
onClick={() => handleBookSlot(slot.courtId, slot.time)}
|
||||||
|
className={slot.available ? 'bg-green-600 hover:bg-green-700' : ''}
|
||||||
|
>
|
||||||
|
{slot.available ? 'Book' : 'Booked'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No Slots Message */}
|
||||||
|
{!loading && courts.length > 0 && bookingSlots.length === 0 && !isPastDate(selectedDate) && (
|
||||||
|
<div className='text-center py-8'>
|
||||||
|
<p className='text-gray-500'>No booking slots available for this date</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,525 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Calendar, Clock, MapPin, ChevronLeft, ChevronRight, Users, User } from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
interface Court {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Booking {
|
||||||
|
id: string;
|
||||||
|
courtId: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
status: string;
|
||||||
|
userId: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BookingSlot {
|
||||||
|
time: string;
|
||||||
|
courtId: string;
|
||||||
|
courtName: string;
|
||||||
|
available: boolean;
|
||||||
|
bookingId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
booking_window_days: string;
|
||||||
|
booking_start_time: string;
|
||||||
|
booking_end_time: string;
|
||||||
|
allow_weekend_bookings: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnhancedBookingCalendar() {
|
||||||
|
const [selectedDate, setSelectedDate] = useState(new Date());
|
||||||
|
const [courts, setCourts] = useState<Court[]>([]);
|
||||||
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
|
const [bookingSlots, setBookingSlots] = useState<BookingSlot[]>([]);
|
||||||
|
const [settings, setSettings] = useState<Settings | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [partnerName, setPartnerName] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
const [showBookingDialog, setShowBookingDialog] = useState(false);
|
||||||
|
const [selectedSlot, setSelectedSlot] = useState<BookingSlot | null>(null);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSettings();
|
||||||
|
fetchCourts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (courts.length > 0 && settings) {
|
||||||
|
fetchBookings();
|
||||||
|
}
|
||||||
|
}, [selectedDate, courts, settings]);
|
||||||
|
|
||||||
|
// Fetch settings from public endpoint (not admin)
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
const settingsMap: Settings = {
|
||||||
|
booking_window_days: '7',
|
||||||
|
booking_start_time: '08:00',
|
||||||
|
booking_end_time: '22:00',
|
||||||
|
allow_weekend_bookings: 'true',
|
||||||
|
};
|
||||||
|
|
||||||
|
data.settings.forEach((setting: any) => {
|
||||||
|
if (setting.key in settingsMap) {
|
||||||
|
settingsMap[setting.key as keyof Settings] = setting.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setSettings(settingsMap);
|
||||||
|
} else {
|
||||||
|
// If settings fetch fails, use defaults
|
||||||
|
setSettings({
|
||||||
|
booking_window_days: '7',
|
||||||
|
booking_start_time: '08:00',
|
||||||
|
booking_end_time: '22:00',
|
||||||
|
allow_weekend_bookings: 'true',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching settings:', error);
|
||||||
|
// Set default settings
|
||||||
|
setSettings({
|
||||||
|
booking_window_days: '7',
|
||||||
|
booking_start_time: '08:00',
|
||||||
|
booking_end_time: '22:00',
|
||||||
|
allow_weekend_bookings: 'true',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch courts from public endpoint (not admin)
|
||||||
|
const fetchCourts = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/courts');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCourts(data.courts.filter((court: Court) => court.isActive));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching courts:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch courts',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchBookings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/bookings');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setBookings(data.bookings);
|
||||||
|
generateBookingSlots(data.bookings);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching bookings:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch bookings',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateTimeSlots = (): string[] => {
|
||||||
|
if (!settings) return [];
|
||||||
|
|
||||||
|
const start = parseInt(settings.booking_start_time.split(':')[0]);
|
||||||
|
const end = parseInt(settings.booking_end_time.split(':')[0]);
|
||||||
|
const slots = [];
|
||||||
|
|
||||||
|
for (let hour = start; hour < end; hour++) {
|
||||||
|
slots.push(`${hour.toString().padStart(2, '0')}:00`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateBookingSlots = (existingBookings: Booking[]) => {
|
||||||
|
const dateStr = selectedDate.toISOString().split('T')[0];
|
||||||
|
const timeSlots = generateTimeSlots();
|
||||||
|
const slots: BookingSlot[] = [];
|
||||||
|
|
||||||
|
courts.forEach((court) => {
|
||||||
|
timeSlots.forEach((time) => {
|
||||||
|
const existingBooking = existingBookings.find(
|
||||||
|
(booking) =>
|
||||||
|
booking.courtId === court.id &&
|
||||||
|
booking.date === dateStr &&
|
||||||
|
booking.startTime === time &&
|
||||||
|
booking.status === 'active'
|
||||||
|
);
|
||||||
|
|
||||||
|
slots.push({
|
||||||
|
time,
|
||||||
|
courtId: court.id,
|
||||||
|
courtName: court.name,
|
||||||
|
available: !existingBooking,
|
||||||
|
bookingId: existingBooking?.id,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setBookingSlots(slots);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDateSelectable = (date: Date): boolean => {
|
||||||
|
if (!settings) return false;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const selectedDateOnly = new Date(date);
|
||||||
|
selectedDateOnly.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
// Check if date is in the past
|
||||||
|
if (selectedDateOnly < today) return false;
|
||||||
|
|
||||||
|
// Check booking window
|
||||||
|
const maxDate = new Date(today);
|
||||||
|
maxDate.setDate(today.getDate() + parseInt(settings.booking_window_days));
|
||||||
|
if (selectedDateOnly > maxDate) return false;
|
||||||
|
|
||||||
|
// Check weekend restrictions
|
||||||
|
if (settings.allow_weekend_bookings === 'false') {
|
||||||
|
const dayOfWeek = selectedDateOnly.getDay();
|
||||||
|
if (dayOfWeek === 0 || dayOfWeek === 6) return false; // Sunday or Saturday
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSlotClick = (slot: BookingSlot) => {
|
||||||
|
if (!slot.available) return;
|
||||||
|
|
||||||
|
setSelectedSlot(slot);
|
||||||
|
setPartnerName('');
|
||||||
|
setNotes('');
|
||||||
|
setShowBookingDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBookingConfirm = async () => {
|
||||||
|
if (!selectedSlot) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const dateStr = selectedDate.toISOString().split('T')[0];
|
||||||
|
const bookingNotes = [];
|
||||||
|
|
||||||
|
if (partnerName.trim()) {
|
||||||
|
bookingNotes.push(`Partner: ${partnerName.trim()}`);
|
||||||
|
}
|
||||||
|
if (notes.trim()) {
|
||||||
|
bookingNotes.push(notes.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/bookings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
courtId: selectedSlot.courtId,
|
||||||
|
date: dateStr,
|
||||||
|
timeSlot: selectedSlot.time,
|
||||||
|
notes: bookingNotes.join(' | '),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Booking created successfully!',
|
||||||
|
});
|
||||||
|
setShowBookingDialog(false);
|
||||||
|
fetchBookings(); // Refresh bookings
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Failed to create booking',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error booking slot:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to create booking',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateDate = (direction: 'prev' | 'next') => {
|
||||||
|
const newDate = new Date(selectedDate);
|
||||||
|
newDate.setDate(newDate.getDate() + (direction === 'next' ? 1 : -1));
|
||||||
|
|
||||||
|
if (isDateSelectable(newDate)) {
|
||||||
|
setSelectedDate(newDate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvailableDates = (): Date[] => {
|
||||||
|
if (!settings) return [];
|
||||||
|
|
||||||
|
const dates: Date[] = [];
|
||||||
|
const today = new Date();
|
||||||
|
const maxDays = parseInt(settings.booking_window_days);
|
||||||
|
|
||||||
|
for (let i = 0; i <= maxDays; i++) {
|
||||||
|
const date = new Date(today);
|
||||||
|
date.setDate(today.getDate() + i);
|
||||||
|
|
||||||
|
if (isDateSelectable(date)) {
|
||||||
|
dates.push(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dates;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPastDate = (date: Date) => {
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return date < today;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isToday = (date: Date) => {
|
||||||
|
const today = new Date();
|
||||||
|
return date.toDateString() === today.toDateString();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!settings) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className='p-6'>
|
||||||
|
<div className='flex items-center justify-center'>
|
||||||
|
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900'></div>
|
||||||
|
<p className='ml-2'>Loading booking system...</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2'>
|
||||||
|
<Calendar className='h-5 w-5' />
|
||||||
|
Book Your Court
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{/* Mobile-friendly date navigation */}
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{/* Quick Date Selection */}
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<h3 className='font-medium'>Select Date</h3>
|
||||||
|
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2'>
|
||||||
|
{getAvailableDates()
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((date, index) => (
|
||||||
|
<Button
|
||||||
|
key={index}
|
||||||
|
variant={
|
||||||
|
date.toDateString() === selectedDate.toDateString()
|
||||||
|
? 'default'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setSelectedDate(date)}
|
||||||
|
className='h-16 flex flex-col'
|
||||||
|
>
|
||||||
|
<span className='text-xs font-normal'>
|
||||||
|
{date.toLocaleDateString('en-US', { weekday: 'short' })}
|
||||||
|
</span>
|
||||||
|
<span className='font-semibold'>{date.getDate()}</span>
|
||||||
|
<span className='text-xs font-normal'>
|
||||||
|
{date.toLocaleDateString('en-US', { month: 'short' })}
|
||||||
|
</span>
|
||||||
|
{isToday(date) && <span className='text-xs text-blue-600'>Today</span>}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Date Display */}
|
||||||
|
<div className='text-center p-4 bg-blue-50 rounded-lg'>
|
||||||
|
<h3 className='text-lg font-semibold text-blue-900'>
|
||||||
|
{selectedDate.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</h3>
|
||||||
|
{isToday(selectedDate) && <span className='text-sm text-blue-600'>Today</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{loading && (
|
||||||
|
<div className='text-center py-8'>
|
||||||
|
<div className='inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900'></div>
|
||||||
|
<p className='mt-2 text-sm text-gray-500'>Loading booking slots...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No Courts Available */}
|
||||||
|
{!loading && courts.length === 0 && (
|
||||||
|
<div className='text-center py-8'>
|
||||||
|
<p className='text-gray-500'>No courts available for booking</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Time Slots Grid */}
|
||||||
|
{!loading && courts.length > 0 && (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<h3 className='font-medium'>Available Time Slots</h3>
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4'>
|
||||||
|
{bookingSlots.map((slot, index) => (
|
||||||
|
<div
|
||||||
|
key={`${slot.courtId}-${slot.time}`}
|
||||||
|
className={`p-4 border rounded-lg transition-colors cursor-pointer ${
|
||||||
|
slot.available
|
||||||
|
? 'border-green-200 bg-green-50 hover:bg-green-100'
|
||||||
|
: 'border-red-200 bg-red-50 cursor-not-allowed'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleSlotClick(slot)}
|
||||||
|
>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='flex items-center gap-2 text-sm font-medium'>
|
||||||
|
<Clock className='h-4 w-4' />
|
||||||
|
{slot.time} -{' '}
|
||||||
|
{String(parseInt(slot.time.split(':')[0]) + 1).padStart(2, '0')}
|
||||||
|
:00
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2 text-sm text-gray-600'>
|
||||||
|
<MapPin className='h-4 w-4' />
|
||||||
|
{slot.courtName}
|
||||||
|
</div>
|
||||||
|
{!slot.available && (
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No Slots Message */}
|
||||||
|
{!loading && courts.length > 0 && bookingSlots.length === 0 && (
|
||||||
|
<div className='text-center py-8'>
|
||||||
|
<p className='text-gray-500'>No booking slots available for this date</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Booking Dialog */}
|
||||||
|
<Dialog open={showBookingDialog} onOpenChange={setShowBookingDialog}>
|
||||||
|
<DialogContent className='sm:max-w-md'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Confirm Your Booking</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{selectedSlot && (
|
||||||
|
<div className='bg-blue-50 p-4 rounded-lg space-y-2'>
|
||||||
|
<div className='flex items-center gap-2 text-sm'>
|
||||||
|
<Calendar className='h-4 w-4' />
|
||||||
|
{selectedDate.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2 text-sm'>
|
||||||
|
<Clock className='h-4 w-4' />
|
||||||
|
{selectedSlot.time} -{' '}
|
||||||
|
{String(parseInt(selectedSlot.time.split(':')[0]) + 1).padStart(2, '0')}:00
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2 text-sm'>
|
||||||
|
<MapPin className='h-4 w-4' />
|
||||||
|
{selectedSlot.courtName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='partner'>Playing Partner (Optional)</Label>
|
||||||
|
<div className='relative'>
|
||||||
|
<User className='absolute left-3 top-3 h-4 w-4 text-gray-400' />
|
||||||
|
<Input
|
||||||
|
id='partner'
|
||||||
|
placeholder='Who will you be playing with?'
|
||||||
|
value={partnerName}
|
||||||
|
onChange={(e) => setPartnerName(e.target.value)}
|
||||||
|
className='pl-10'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className='text-xs text-gray-500'>Enter the name of the person you'll be playing with</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='notes'>Additional Notes (Optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id='notes'
|
||||||
|
placeholder='Any additional information...'
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
className='min-h-[80px]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex gap-2 pt-4'>
|
||||||
|
<Button variant='outline' className='flex-1' onClick={() => setShowBookingDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className='flex-1' onClick={handleBookingConfirm} disabled={loading}>
|
||||||
|
{loading ? 'Booking...' : 'Confirm Booking'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Calendar, Clock, MapPin, Edit, Trash2, User, RefreshCw } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
interface Booking {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
status: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
court: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserBookingManagement() {
|
||||||
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [selectedBooking, setSelectedBooking] = useState<Booking | null>(null);
|
||||||
|
const [editNotes, setEditNotes] = useState('');
|
||||||
|
const [editPartner, setEditPartner] = useState('');
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBookings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchBookings = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/bookings');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
// Filter to show only future and today's bookings
|
||||||
|
const now = new Date();
|
||||||
|
const today = now.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const relevantBookings = data.bookings.filter((booking: Booking) => {
|
||||||
|
if (booking.status !== 'active') return false;
|
||||||
|
return booking.date >= today;
|
||||||
|
});
|
||||||
|
|
||||||
|
setBookings(relevantBookings);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching bookings:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch your bookings',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 handleEditClick = (booking: Booking) => {
|
||||||
|
setSelectedBooking(booking);
|
||||||
|
const { partner, additionalNotes } = parseBookingNotes(booking.notes);
|
||||||
|
setEditPartner(partner);
|
||||||
|
setEditNotes(additionalNotes);
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClick = (booking: Booking) => {
|
||||||
|
setSelectedBooking(booking);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async () => {
|
||||||
|
if (!selectedBooking) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const bookingNotes = [];
|
||||||
|
if (editPartner.trim()) {
|
||||||
|
bookingNotes.push(`Partner: ${editPartner.trim()}`);
|
||||||
|
}
|
||||||
|
if (editNotes.trim()) {
|
||||||
|
bookingNotes.push(editNotes.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/bookings/${selectedBooking.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
notes: bookingNotes.join(' | '),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Booking updated successfully',
|
||||||
|
});
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
fetchBookings();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Failed to update booking',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating booking:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to update booking',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!selectedBooking) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/bookings/${selectedBooking.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Booking cancelled successfully',
|
||||||
|
});
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
fetchBookings();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Failed to cancel booking',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error cancelling booking:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to cancel booking',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return format(date, 'EEE, MMM dd');
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isToday = (dateStr: string) => {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
return dateStr === today;
|
||||||
|
};
|
||||||
|
|
||||||
|
const canModifyBooking = (booking: Booking) => {
|
||||||
|
const bookingDateTime = new Date(`${booking.date}T${booking.startTime}`);
|
||||||
|
const now = new Date();
|
||||||
|
const timeDiff = bookingDateTime.getTime() - now.getTime();
|
||||||
|
const hoursDiff = timeDiff / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
// Allow modifications if booking is more than 2 hours away
|
||||||
|
return hoursDiff > 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='pb-3'>
|
||||||
|
<CardTitle className='text-base flex items-center gap-2'>
|
||||||
|
<Calendar className='h-4 w-4' />
|
||||||
|
Your Bookings
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className='animate-pulse border rounded-lg p-4'>
|
||||||
|
<div className='h-4 bg-gray-200 rounded w-3/4 mb-2'></div>
|
||||||
|
<div className='h-3 bg-gray-200 rounded w-1/2'></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='pb-3 flex flex-row items-center justify-between'>
|
||||||
|
<CardTitle className='text-base flex items-center gap-2'>
|
||||||
|
<Calendar className='h-4 w-4' />
|
||||||
|
Your Bookings
|
||||||
|
</CardTitle>
|
||||||
|
<Button size='sm' variant='outline' onClick={fetchBookings}>
|
||||||
|
<RefreshCw className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{bookings.length === 0 ? (
|
||||||
|
<div className='text-sm text-gray-500 text-center py-6'>
|
||||||
|
No upcoming bookings. Make your first booking!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{bookings.map((booking) => {
|
||||||
|
const { partner, additionalNotes } = parseBookingNotes(booking.notes);
|
||||||
|
const canModify = canModifyBooking(booking);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={booking.id} className='border rounded-lg p-4 space-y-3'>
|
||||||
|
<div className='flex items-start justify-between'>
|
||||||
|
<div className='space-y-2 flex-1'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<MapPin className='h-4 w-4 text-blue-600' />
|
||||||
|
<span className='font-medium text-sm'>{booking.court.name}</span>
|
||||||
|
{isToday(booking.date) && (
|
||||||
|
<Badge variant='secondary' className='text-xs'>
|
||||||
|
Today
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center gap-4 text-xs text-gray-500'>
|
||||||
|
<div className='flex items-center gap-1'>
|
||||||
|
<Calendar className='h-3 w-3' />
|
||||||
|
<span>{formatDate(booking.date)}</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-1'>
|
||||||
|
<Clock className='h-3 w-3' />
|
||||||
|
<span>
|
||||||
|
{booking.startTime} - {booking.endTime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{partner && (
|
||||||
|
<div className='flex items-center gap-1 text-xs text-gray-600'>
|
||||||
|
<User className='h-3 w-3' />
|
||||||
|
<span>Playing with: {partner}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{additionalNotes && (
|
||||||
|
<p className='text-xs text-gray-600 italic bg-gray-50 p-2 rounded'>
|
||||||
|
{additionalNotes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex gap-1 ml-2'>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outline'
|
||||||
|
onClick={() => handleEditClick(booking)}
|
||||||
|
disabled={!canModify}
|
||||||
|
className='h-8 w-8 p-0'
|
||||||
|
>
|
||||||
|
<Edit className='h-3 w-3' />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size='sm'
|
||||||
|
variant='outline'
|
||||||
|
onClick={() => handleDeleteClick(booking)}
|
||||||
|
disabled={!canModify}
|
||||||
|
className='h-8 w-8 p-0 text-red-600 hover:text-red-700'
|
||||||
|
>
|
||||||
|
<Trash2 className='h-3 w-3' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canModify && (
|
||||||
|
<p className='text-xs text-amber-600 bg-amber-50 p-2 rounded'>
|
||||||
|
Booking can only be modified more than 2 hours before the session
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Edit Dialog */}
|
||||||
|
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||||
|
<DialogContent className='sm:max-w-md'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Booking</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{selectedBooking && (
|
||||||
|
<div className='bg-blue-50 p-4 rounded-lg space-y-2'>
|
||||||
|
<div className='flex items-center gap-2 text-sm'>
|
||||||
|
<Calendar className='h-4 w-4' />
|
||||||
|
{formatDate(selectedBooking.date)}
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2 text-sm'>
|
||||||
|
<Clock className='h-4 w-4' />
|
||||||
|
{selectedBooking.startTime} - {selectedBooking.endTime}
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2 text-sm'>
|
||||||
|
<MapPin className='h-4 w-4' />
|
||||||
|
{selectedBooking.court.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='edit-partner'>Playing Partner</Label>
|
||||||
|
<div className='relative'>
|
||||||
|
<User className='absolute left-3 top-3 h-4 w-4 text-gray-400' />
|
||||||
|
<Input
|
||||||
|
id='edit-partner'
|
||||||
|
placeholder='Who will you be playing with?'
|
||||||
|
value={editPartner}
|
||||||
|
onChange={(e) => setEditPartner(e.target.value)}
|
||||||
|
className='pl-10'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='edit-notes'>Additional Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
id='edit-notes'
|
||||||
|
placeholder='Any additional information...'
|
||||||
|
value={editNotes}
|
||||||
|
onChange={(e) => setEditNotes(e.target.value)}
|
||||||
|
className='min-h-[80px]'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex gap-2 pt-4'>
|
||||||
|
<Button variant='outline' className='flex-1' onClick={() => setEditDialogOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className='flex-1' onClick={handleEditSave}>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Cancel Booking</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to cancel this booking? This action cannot be undone.
|
||||||
|
{selectedBooking && (
|
||||||
|
<div className='mt-3 p-3 bg-gray-50 rounded'>
|
||||||
|
<p className='text-sm font-medium'>
|
||||||
|
{selectedBooking.court.name} - {formatDate(selectedBooking.date)}
|
||||||
|
</p>
|
||||||
|
<p className='text-sm text-gray-600'>
|
||||||
|
{selectedBooking.startTime} - {selectedBooking.endTime}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Keep Booking</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDeleteConfirm} className='bg-red-600 hover:bg-red-700'>
|
||||||
|
Cancel Booking
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { CalendarIcon, Clock, MapPin } from 'lucide-react';
|
||||||
|
|
||||||
|
const timeSlots = [
|
||||||
|
'09:00',
|
||||||
|
'10:00',
|
||||||
|
'11:00',
|
||||||
|
'12:00',
|
||||||
|
'13:00',
|
||||||
|
'14:00',
|
||||||
|
'15:00',
|
||||||
|
'16:00',
|
||||||
|
'17:00',
|
||||||
|
'18:00',
|
||||||
|
'19:00',
|
||||||
|
'20:00',
|
||||||
|
];
|
||||||
|
|
||||||
|
const courts = [
|
||||||
|
{ id: 'court-1', name: 'Court 1', isActive: true },
|
||||||
|
{ id: 'court-2', name: 'Court 2', isActive: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BookingCalendar() {
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date>(new Date());
|
||||||
|
const [selectedSlot, setSelectedSlot] = useState<string | null>(null);
|
||||||
|
const [selectedCourt, setSelectedCourt] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBooking = async () => {
|
||||||
|
if (!selectedDate || !selectedSlot || !selectedCourt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/bookings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
courtId: selectedCourt,
|
||||||
|
date: selectedDate.toISOString().split('T')[0],
|
||||||
|
timeSlot: selectedSlot,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Reset selections and show success
|
||||||
|
setSelectedSlot(null);
|
||||||
|
setSelectedCourt(null);
|
||||||
|
// Show success message
|
||||||
|
alert('Booking created successfully!');
|
||||||
|
} else {
|
||||||
|
alert(result.error || 'Booking failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Booking error:', error);
|
||||||
|
alert('An error occurred while creating the booking');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='grid gap-6 lg:grid-cols-2'>
|
||||||
|
{/* Calendar */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2'>
|
||||||
|
<CalendarIcon className='h-5 w-5' />
|
||||||
|
Select Date
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Choose the date for your table tennis session</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Calendar
|
||||||
|
mode='single'
|
||||||
|
selected={selectedDate}
|
||||||
|
onSelect={(date) => date && setSelectedDate(date)}
|
||||||
|
disabled={(date) => date < new Date() || date.getDay() === 0} // Disable past dates and Sundays
|
||||||
|
className='rounded-md border'
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Time Slots and Courts */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2'>
|
||||||
|
<Clock className='h-5 w-5' />
|
||||||
|
Available Slots
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>{selectedDate ? formatDate(selectedDate) : 'Select a date first'}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-6'>
|
||||||
|
{/* Court Selection */}
|
||||||
|
<div>
|
||||||
|
<h4 className='font-medium mb-3 flex items-center gap-2'>
|
||||||
|
<MapPin className='h-4 w-4' />
|
||||||
|
Select Court
|
||||||
|
</h4>
|
||||||
|
<div className='grid grid-cols-2 gap-2'>
|
||||||
|
{courts.map((court) => (
|
||||||
|
<Button
|
||||||
|
key={court.id}
|
||||||
|
variant={selectedCourt === court.id ? 'default' : 'outline'}
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setSelectedCourt(court.id)}
|
||||||
|
disabled={!court.isActive}
|
||||||
|
>
|
||||||
|
{court.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Slot Selection */}
|
||||||
|
<div>
|
||||||
|
<h4 className='font-medium mb-3 flex items-center gap-2'>
|
||||||
|
<Clock className='h-4 w-4' />
|
||||||
|
Select Time
|
||||||
|
</h4>
|
||||||
|
<div className='grid grid-cols-3 gap-2'>
|
||||||
|
{timeSlots.map((time) => {
|
||||||
|
const isBooked = Math.random() > 0.7; // Simulate some bookings
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={time}
|
||||||
|
variant={selectedSlot === time ? 'default' : 'outline'}
|
||||||
|
size='sm'
|
||||||
|
onClick={() => !isBooked && setSelectedSlot(time)}
|
||||||
|
disabled={isBooked}
|
||||||
|
className='relative'
|
||||||
|
>
|
||||||
|
{time}
|
||||||
|
{isBooked && (
|
||||||
|
<Badge
|
||||||
|
variant='destructive'
|
||||||
|
className='absolute -top-1 -right-1 h-2 w-2 p-0'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Booking Summary */}
|
||||||
|
{selectedDate && selectedSlot && selectedCourt && (
|
||||||
|
<div className='bg-blue-50 border border-blue-200 rounded-lg p-4 space-y-2'>
|
||||||
|
<h4 className='font-medium text-blue-900'>Booking Summary</h4>
|
||||||
|
<div className='text-sm text-blue-700 space-y-1'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<CalendarIcon className='h-3 w-3' />
|
||||||
|
{formatDate(selectedDate)}
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Clock className='h-3 w-3' />
|
||||||
|
{selectedSlot} - {String(parseInt(selectedSlot.split(':')[0]) + 1).padStart(2, '0')}
|
||||||
|
:00
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<MapPin className='h-3 w-3' />
|
||||||
|
{courts.find((c) => c.id === selectedCourt)?.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleBooking} className='w-full mt-3'>
|
||||||
|
Confirm Booking
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Bell, LogOut, Settings, User, Calendar } from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
interface DashboardHeaderProps {
|
||||||
|
user: {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: 'user' | 'admin';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardHeader({ user }: DashboardHeaderProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
setIsLoggingOut(true);
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Logged out successfully',
|
||||||
|
description: 'See you next time!',
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push('/login');
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Logout failed',
|
||||||
|
description: 'Please try again',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoggingOut(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className='bg-white/80 backdrop-blur-md border-b border-gray-200 sticky top-0 z-50'>
|
||||||
|
<div className='container mx-auto px-4'>
|
||||||
|
<div className='flex items-center justify-between h-16'>
|
||||||
|
<div className='flex items-center space-x-4'>
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<Calendar className='h-6 w-6 text-blue-600' />
|
||||||
|
<h1 className='text-xl font-bold text-gray-900'>TT Booking</h1>
|
||||||
|
</div>
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<Badge variant='secondary' className='bg-purple-100 text-purple-800'>
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center space-x-4'>
|
||||||
|
<Button variant='ghost' size='sm'>
|
||||||
|
<Bell className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<Button variant='ghost' size='sm' onClick={() => router.push('/admin')}>
|
||||||
|
<Settings className='h-4 w-4 mr-2' />
|
||||||
|
Admin Panel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<User className='h-4 w-4 text-gray-600' />
|
||||||
|
<span className='text-sm text-gray-700'>{user.email.split('@')[0]}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant='outline' size='sm' onClick={handleLogout} disabled={isLoggingOut}>
|
||||||
|
<LogOut className='h-4 w-4 mr-2' />
|
||||||
|
{isLoggingOut ? 'Logging out...' : 'Logout'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Calendar, Clock, Users, MapPin, TrendingUp, Activity } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
totalUsers: number;
|
||||||
|
todayBookings: number;
|
||||||
|
activeCourts: number;
|
||||||
|
userBookings: number;
|
||||||
|
upcomingBookings: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickStats() {
|
||||||
|
const [stats, setStats] = useState<DashboardStats>({
|
||||||
|
totalUsers: 0,
|
||||||
|
todayBookings: 0,
|
||||||
|
activeCourts: 0,
|
||||||
|
userBookings: 0,
|
||||||
|
upcomingBookings: 0,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/dashboard/stats');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setStats(data.stats);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard stats:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<Card>
|
||||||
|
<CardContent className='p-6'>
|
||||||
|
<div className='animate-pulse'>
|
||||||
|
<div className='h-4 bg-gray-200 rounded w-3/4 mb-4'></div>
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className='flex justify-between'>
|
||||||
|
<div className='h-3 bg-gray-200 rounded w-1/2'></div>
|
||||||
|
<div className='h-3 bg-gray-200 rounded w-1/4'></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='pb-3'>
|
||||||
|
<CardTitle className='text-base'>Quick Stats</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Calendar className='h-4 w-4 text-blue-600' />
|
||||||
|
<span className='text-sm'>Your Bookings</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant='secondary'>{stats.userBookings} active</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Clock className='h-4 w-4 text-green-600' />
|
||||||
|
<span className='text-sm'>Upcoming</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant='secondary'>{stats.upcomingBookings} bookings</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<MapPin className='h-4 w-4 text-purple-600' />
|
||||||
|
<span className='text-sm'>Active Courts</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant='secondary'>{stats.activeCourts} available</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Activity className='h-4 w-4 text-orange-600' />
|
||||||
|
<span className='text-sm'>Today\'s Bookings</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant='secondary'>{stats.todayBookings} total</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='pb-3'>
|
||||||
|
<CardTitle className='text-base'>System Info</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-3'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Users className='h-4 w-4 text-gray-600' />
|
||||||
|
<span className='text-sm'>Total Users</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant='outline'>{stats.totalUsers}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<TrendingUp className='h-4 w-4 text-green-600' />
|
||||||
|
<span className='text-sm'>System Status</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<div className='h-2 w-2 bg-green-500 rounded-full' />
|
||||||
|
<span className='text-xs text-green-600'>Online</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Calendar, Clock, Users, MapPin, TrendingUp, Activity } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DashboardStats {
|
||||||
|
totalUsers: number;
|
||||||
|
todayBookings: number;
|
||||||
|
activeCourts: number;
|
||||||
|
userBookings: number;
|
||||||
|
upcomingBookings: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function QuickStats() {
|
||||||
|
const [stats, setStats] = useState<DashboardStats>({
|
||||||
|
totalUsers: 0,
|
||||||
|
todayBookings: 0,
|
||||||
|
activeCourts: 0,
|
||||||
|
userBookings: 0,
|
||||||
|
upcomingBookings: 0,
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStats();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/dashboard/stats');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setStats(data.stats);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching dashboard stats:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<Card>
|
||||||
|
<CardContent className='p-6'>
|
||||||
|
<div className='animate-pulse'>
|
||||||
|
<div className='h-4 bg-gray-200 rounded w-3/4 mb-4'></div>
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className='flex justify-between'>
|
||||||
|
<div className='h-3 bg-gray-200 rounded w-1/2'></div>
|
||||||
|
<div className='h-3 bg-gray-200 rounded w-1/4'></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='pb-3'>
|
||||||
|
<CardTitle className='text-base'>Quick Stats</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-4'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Calendar className='h-4 w-4 text-blue-600' />
|
||||||
|
<span className='text-sm'>Your Bookings</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant='secondary'>{stats.userBookings} active</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Clock className='h-4 w-4 text-green-600' />
|
||||||
|
<span className='text-sm'>Upcoming</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant='secondary'>{stats.upcomingBookings} bookings</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<MapPin className='h-4 w-4 text-purple-600' />
|
||||||
|
<span className='text-sm'>Active Courts</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant='secondary'>{stats.activeCourts} available</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Activity className='h-4 w-4 text-orange-600' />
|
||||||
|
<span className='text-sm'>Today\'s Bookings</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant='secondary'>{stats.todayBookings} total</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='pb-3'>
|
||||||
|
<CardTitle className='text-base'>System Info</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-3'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Users className='h-4 w-4 text-gray-600' />
|
||||||
|
<span className='text-sm'>Total Users</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant='outline'>{stats.totalUsers}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<TrendingUp className='h-4 w-4 text-green-600' />
|
||||||
|
<span className='text-sm'>System Status</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<div className='h-2 w-2 bg-green-500 rounded-full' />
|
||||||
|
<span className='text-xs text-green-600'>Online</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Calendar, Clock, MapPin } from 'lucide-react';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
|
interface Booking {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
status: string;
|
||||||
|
notes?: string;
|
||||||
|
createdAt: Date;
|
||||||
|
court: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
surname: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecentBookings() {
|
||||||
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRecentBookings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchRecentBookings = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await fetch('/api/dashboard/recent-bookings');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setBookings(data.bookings || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching recent bookings:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return format(date, 'MMM dd');
|
||||||
|
} catch {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='pb-3'>
|
||||||
|
<CardTitle className='text-base'>Recent Bookings</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<div key={i} className='animate-pulse'>
|
||||||
|
<div className='h-4 bg-gray-200 rounded w-3/4 mb-2'></div>
|
||||||
|
<div className='h-3 bg-gray-200 rounded w-1/2'></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className='pb-3'>
|
||||||
|
<CardTitle className='text-base'>Recent Bookings</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{bookings.length === 0 ? (
|
||||||
|
<div className='text-sm text-gray-500 text-center py-6'>
|
||||||
|
No recent bookings yet. Make your first booking!
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{bookings.map((booking) => (
|
||||||
|
<div key={booking.id} className='border rounded-lg p-3 space-y-2'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<MapPin className='h-4 w-4 text-blue-600' />
|
||||||
|
<span className='font-medium text-sm'>{booking.court.name}</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant={booking.status === 'active' ? 'default' : 'secondary'}>
|
||||||
|
{booking.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center gap-4 text-xs text-gray-500'>
|
||||||
|
<div className='flex items-center gap-1'>
|
||||||
|
<Calendar className='h-3 w-3' />
|
||||||
|
<span>{formatDate(booking.date)}</span>
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-1'>
|
||||||
|
<Clock className='h-3 w-3' />
|
||||||
|
<span>
|
||||||
|
{booking.startTime} - {booking.endTime}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{booking.notes && <p className='text-xs text-gray-600 italic'>{booking.notes}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
|
import { Bell, LogOut, Settings, User, Calendar } from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { NotificationBell, AnnouncementsModal } from '@/components/notifications/announcements';
|
||||||
|
import { UserProfile } from '@/components/user/user-profile';
|
||||||
|
|
||||||
|
interface DashboardHeaderProps {
|
||||||
|
user: {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: 'user' | 'admin';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardHeader({ user }: DashboardHeaderProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [isLoggingOut, setIsLoggingOut] = useState(false);
|
||||||
|
const [showAnnouncements, setShowAnnouncements] = useState(false);
|
||||||
|
const [showUserProfile, setShowUserProfile] = useState(false);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
|
// Fetch unread announcements count on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUnreadCount();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchUnreadCount = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/announcements');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setUnreadCount(data.unreadCount || 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching unread count:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
setIsLoggingOut(true);
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: 'Logged out successfully',
|
||||||
|
description: 'See you next time!',
|
||||||
|
});
|
||||||
|
|
||||||
|
router.push('/login');
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: 'Logout failed',
|
||||||
|
description: 'Please try again',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoggingOut(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className='bg-white/80 backdrop-blur-md border-b border-gray-200 sticky top-0 z-50'>
|
||||||
|
<div className='container mx-auto px-4'>
|
||||||
|
<div className='flex items-center justify-between h-16'>
|
||||||
|
<div className='flex items-center space-x-4'>
|
||||||
|
<div className='flex items-center space-x-2'>
|
||||||
|
<Calendar className='h-6 w-6 text-blue-600' />
|
||||||
|
<h1 className='text-xl font-bold text-gray-900'>TT Booking</h1>
|
||||||
|
</div>
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<Badge variant='secondary' className='bg-purple-100 text-purple-800'>
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex items-center space-x-4'>
|
||||||
|
<NotificationBell unreadCount={unreadCount} onClick={() => setShowAnnouncements(true)} />
|
||||||
|
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<Button variant='ghost' size='sm' onClick={() => router.push('/admin')}>
|
||||||
|
<Settings className='h-4 w-4 mr-2' />
|
||||||
|
Admin Panel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setShowUserProfile(true)}
|
||||||
|
className='flex items-center space-x-2'
|
||||||
|
>
|
||||||
|
<User className='h-4 w-4 text-gray-600' />
|
||||||
|
<span className='text-sm text-gray-700'>{user.email.split('@')[0]}</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant='outline' size='sm' onClick={handleLogout} disabled={isLoggingOut}>
|
||||||
|
<LogOut className='h-4 w-4 mr-2' />
|
||||||
|
{isLoggingOut ? 'Logging out...' : 'Logout'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Announcements Modal */}
|
||||||
|
<AnnouncementsModal
|
||||||
|
isOpen={showAnnouncements}
|
||||||
|
onClose={() => setShowAnnouncements(false)}
|
||||||
|
unreadCount={unreadCount}
|
||||||
|
onCountUpdate={setUnreadCount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* User Profile Modal */}
|
||||||
|
<Dialog open={showUserProfile} onOpenChange={setShowUserProfile}>
|
||||||
|
<DialogContent className='sm:max-w-4xl max-h-[90vh] overflow-y-auto'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>User Profile</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<UserProfile />
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Bell, X, AlertCircle, Info, AlertTriangle } from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
interface Announcement {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
priority: 'low' | 'medium' | 'high';
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnouncementsProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
unreadCount: number;
|
||||||
|
onCountUpdate: (count: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnnouncementsModal({ isOpen, onClose, unreadCount, onCountUpdate }: AnnouncementsProps) {
|
||||||
|
const [announcements, setAnnouncements] = useState<Announcement[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchAnnouncements();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const fetchAnnouncements = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/announcements');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setAnnouncements(data.announcements || []);
|
||||||
|
onCountUpdate(data.unreadCount || 0);
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch announcements',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching announcements:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch announcements',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityIcon = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high':
|
||||||
|
return <AlertCircle className='h-4 w-4 text-red-500' />;
|
||||||
|
case 'medium':
|
||||||
|
return <AlertTriangle className='h-4 w-4 text-yellow-500' />;
|
||||||
|
default:
|
||||||
|
return <Info className='h-4 w-4 text-blue-500' />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
switch (priority) {
|
||||||
|
case 'high':
|
||||||
|
return 'border-red-200 bg-red-50';
|
||||||
|
case 'medium':
|
||||||
|
return 'border-yellow-200 bg-yellow-50';
|
||||||
|
default:
|
||||||
|
return 'border-blue-200 bg-blue-50';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className='sm:max-w-2xl max-h-[80vh] overflow-hidden flex flex-col'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className='flex items-center gap-2'>
|
||||||
|
<Bell className='h-5 w-5' />
|
||||||
|
Announcements
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Badge variant='destructive' className='text-xs'>
|
||||||
|
{unreadCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className='flex-1 overflow-y-auto'>
|
||||||
|
{loading ? (
|
||||||
|
<div className='flex items-center justify-center py-8'>
|
||||||
|
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900'></div>
|
||||||
|
<p className='ml-2'>Loading announcements...</p>
|
||||||
|
</div>
|
||||||
|
) : announcements.length === 0 ? (
|
||||||
|
<div className='text-center py-8 text-gray-500'>
|
||||||
|
<Bell className='h-12 w-12 mx-auto mb-4 text-gray-300' />
|
||||||
|
<p>No announcements at this time</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{announcements.map((announcement) => (
|
||||||
|
<Card
|
||||||
|
key={announcement.id}
|
||||||
|
className={`${getPriorityColor(announcement.priority)} border-l-4`}
|
||||||
|
>
|
||||||
|
<CardHeader className='pb-2'>
|
||||||
|
<CardTitle className='flex items-center gap-2 text-base'>
|
||||||
|
{getPriorityIcon(announcement.priority)}
|
||||||
|
{announcement.title}
|
||||||
|
<Badge variant='outline' className='ml-auto text-xs'>
|
||||||
|
{announcement.priority}
|
||||||
|
</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='pt-0'>
|
||||||
|
<p className='text-sm text-gray-700 mb-2'>{announcement.content}</p>
|
||||||
|
<p className='text-xs text-gray-500'>{formatDate(announcement.createdAt)}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex justify-end pt-4 border-t'>
|
||||||
|
<Button onClick={onClose} variant='outline'>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bell button component for header
|
||||||
|
interface NotificationBellProps {
|
||||||
|
unreadCount: number;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationBell({ unreadCount, onClick }: NotificationBellProps) {
|
||||||
|
return (
|
||||||
|
<Button variant='ghost' size='sm' onClick={onClick} className='relative'>
|
||||||
|
<Bell className='h-4 w-4' />
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant='destructive'
|
||||||
|
className='absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center text-xs p-0 min-w-[20px]'
|
||||||
|
>
|
||||||
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import { ThemeProvider as NextThemesProvider } from 'next-themes';
|
||||||
|
import { type ThemeProviderProps } from 'next-themes/dist/types';
|
||||||
|
|
||||||
|
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { buttonVariants } from '@/components/ui/button';
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root;
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
));
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||||
|
);
|
||||||
|
AlertDialogHeader.displayName = 'AlertDialogHeader';
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||||
|
);
|
||||||
|
AlertDialogFooter.displayName = 'AlertDialogFooter';
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title ref={ref} className={cn('text-lg font-semibold', className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName;
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action ref={ref} className={cn(buttonVariants(), className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
|
destructive:
|
||||||
|
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants };
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Slot } from '@radix-ui/react-slot';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||||
|
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||||
|
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2',
|
||||||
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
|
lg: 'h-10 rounded-md px-8',
|
||||||
|
icon: 'h-9 w-9',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface ButtonProps
|
||||||
|
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button';
|
||||||
|
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Button.displayName = 'Button';
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ChevronDownIcon,
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button"
|
||||||
|
|
||||||
|
function Calendar({
|
||||||
|
className,
|
||||||
|
classNames,
|
||||||
|
showOutsideDays = true,
|
||||||
|
captionLayout = "label",
|
||||||
|
buttonVariant = "ghost",
|
||||||
|
formatters,
|
||||||
|
components,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayPicker> & {
|
||||||
|
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||||
|
}) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DayPicker
|
||||||
|
showOutsideDays={showOutsideDays}
|
||||||
|
className={cn(
|
||||||
|
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||||
|
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||||
|
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
captionLayout={captionLayout}
|
||||||
|
formatters={{
|
||||||
|
formatMonthDropdown: (date) =>
|
||||||
|
date.toLocaleString("default", { month: "short" }),
|
||||||
|
...formatters,
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
root: cn("w-fit", defaultClassNames.root),
|
||||||
|
months: cn(
|
||||||
|
"relative flex flex-col gap-4 md:flex-row",
|
||||||
|
defaultClassNames.months
|
||||||
|
),
|
||||||
|
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||||
|
nav: cn(
|
||||||
|
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||||
|
defaultClassNames.nav
|
||||||
|
),
|
||||||
|
button_previous: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_previous
|
||||||
|
),
|
||||||
|
button_next: cn(
|
||||||
|
buttonVariants({ variant: buttonVariant }),
|
||||||
|
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||||
|
defaultClassNames.button_next
|
||||||
|
),
|
||||||
|
month_caption: cn(
|
||||||
|
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
|
||||||
|
defaultClassNames.month_caption
|
||||||
|
),
|
||||||
|
dropdowns: cn(
|
||||||
|
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||||
|
defaultClassNames.dropdowns
|
||||||
|
),
|
||||||
|
dropdown_root: cn(
|
||||||
|
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||||
|
defaultClassNames.dropdown_root
|
||||||
|
),
|
||||||
|
dropdown: cn(
|
||||||
|
"bg-popover absolute inset-0 opacity-0",
|
||||||
|
defaultClassNames.dropdown
|
||||||
|
),
|
||||||
|
caption_label: cn(
|
||||||
|
"select-none font-medium",
|
||||||
|
captionLayout === "label"
|
||||||
|
? "text-sm"
|
||||||
|
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||||
|
defaultClassNames.caption_label
|
||||||
|
),
|
||||||
|
table: "w-full border-collapse",
|
||||||
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
|
weekday: cn(
|
||||||
|
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||||
|
defaultClassNames.weekday
|
||||||
|
),
|
||||||
|
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||||
|
week_number_header: cn(
|
||||||
|
"w-[--cell-size] select-none",
|
||||||
|
defaultClassNames.week_number_header
|
||||||
|
),
|
||||||
|
week_number: cn(
|
||||||
|
"text-muted-foreground select-none text-[0.8rem]",
|
||||||
|
defaultClassNames.week_number
|
||||||
|
),
|
||||||
|
day: cn(
|
||||||
|
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||||
|
defaultClassNames.day
|
||||||
|
),
|
||||||
|
range_start: cn(
|
||||||
|
"bg-accent rounded-l-md",
|
||||||
|
defaultClassNames.range_start
|
||||||
|
),
|
||||||
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
|
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||||
|
today: cn(
|
||||||
|
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||||
|
defaultClassNames.today
|
||||||
|
),
|
||||||
|
outside: cn(
|
||||||
|
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||||
|
defaultClassNames.outside
|
||||||
|
),
|
||||||
|
disabled: cn(
|
||||||
|
"text-muted-foreground opacity-50",
|
||||||
|
defaultClassNames.disabled
|
||||||
|
),
|
||||||
|
hidden: cn("invisible", defaultClassNames.hidden),
|
||||||
|
...classNames,
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Root: ({ className, rootRef, ...props }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="calendar"
|
||||||
|
ref={rootRef}
|
||||||
|
className={cn(className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Chevron: ({ className, orientation, ...props }) => {
|
||||||
|
if (orientation === "left") {
|
||||||
|
return (
|
||||||
|
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orientation === "right") {
|
||||||
|
return (
|
||||||
|
<ChevronRightIcon
|
||||||
|
className={cn("size-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: CalendarDayButton,
|
||||||
|
WeekNumber: ({ children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<td {...props}>
|
||||||
|
<div className="flex size-[--cell-size] items-center justify-center text-center">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
...components,
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarDayButton({
|
||||||
|
className,
|
||||||
|
day,
|
||||||
|
modifiers,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) {
|
||||||
|
const defaultClassNames = getDefaultClassNames()
|
||||||
|
|
||||||
|
const ref = React.useRef<HTMLButtonElement>(null)
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (modifiers.focused) ref.current?.focus()
|
||||||
|
}, [modifiers.focused])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
ref={ref}
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
data-day={day.date.toLocaleDateString()}
|
||||||
|
data-selected-single={
|
||||||
|
modifiers.selected &&
|
||||||
|
!modifiers.range_start &&
|
||||||
|
!modifiers.range_end &&
|
||||||
|
!modifiers.range_middle
|
||||||
|
}
|
||||||
|
data-range-start={modifiers.range_start}
|
||||||
|
data-range-end={modifiers.range_end}
|
||||||
|
data-range-middle={modifiers.range_middle}
|
||||||
|
className={cn(
|
||||||
|
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
|
||||||
|
defaultClassNames.day,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Calendar, CalendarDayButton }
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
|
||||||
|
));
|
||||||
|
Card.displayName = 'Card';
|
||||||
|
|
||||||
|
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardHeader.displayName = 'CardHeader';
|
||||||
|
|
||||||
|
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardTitle.displayName = 'CardTitle';
|
||||||
|
|
||||||
|
const CardDescription = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => <div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||||
|
);
|
||||||
|
CardContent.displayName = 'CardContent';
|
||||||
|
|
||||||
|
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
CardFooter.displayName = 'CardFooter';
|
||||||
|
|
||||||
|
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||||
|
import { X } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Dialog = DialogPrimitive.Root
|
||||||
|
|
||||||
|
const DialogTrigger = DialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const DialogPortal = DialogPrimitive.Portal
|
||||||
|
|
||||||
|
const DialogClose = DialogPrimitive.Close
|
||||||
|
|
||||||
|
const DialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
const DialogFooter = ({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
DialogFooter.displayName = "DialogFooter"
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"text-lg font-semibold leading-none tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogFooter,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { Slot } from "@radix-ui/react-slot"
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
|
||||||
|
const Form = FormProvider
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
> = {
|
||||||
|
name: TName
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext)
|
||||||
|
const itemContext = React.useContext(FormItemContext)
|
||||||
|
const { getFieldState, formState } = useFormContext()
|
||||||
|
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState)
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>")
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue
|
||||||
|
)
|
||||||
|
|
||||||
|
const FormItem = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const id = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormItem.displayName = "FormItem"
|
||||||
|
|
||||||
|
const FormLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { error, formItemId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn(error && "text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormLabel.displayName = "FormLabel"
|
||||||
|
|
||||||
|
const FormControl = React.forwardRef<
|
||||||
|
React.ElementRef<typeof Slot>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof Slot>
|
||||||
|
>(({ ...props }, ref) => {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
ref={ref}
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormControl.displayName = "FormControl"
|
||||||
|
|
||||||
|
const FormDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
const { formDescriptionId } = useFormField()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-[0.8rem] text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormDescription.displayName = "FormDescription"
|
||||||
|
|
||||||
|
const FormMessage = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, children, ...props }, ref) => {
|
||||||
|
const { error, formMessageId } = useFormField()
|
||||||
|
const body = error ? String(error?.message ?? "") : children
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
ref={ref}
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-[0.8rem] font-medium text-destructive", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormMessage.displayName = "FormMessage"
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input };
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const labelVariants = cva(
|
||||||
|
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Label = React.forwardRef<
|
||||||
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||||
|
VariantProps<typeof labelVariants>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(labelVariants(), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||||
|
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName =
|
||||||
|
SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Switch = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SwitchPrimitives.Root
|
||||||
|
className={cn(
|
||||||
|
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<SwitchPrimitives.Thumb
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitives.Root>
|
||||||
|
))
|
||||||
|
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||||
|
|
||||||
|
export { Switch }
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Table = React.forwardRef<
|
||||||
|
HTMLTableElement,
|
||||||
|
React.HTMLAttributes<HTMLTableElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="relative w-full overflow-auto">
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
Table.displayName = "Table"
|
||||||
|
|
||||||
|
const TableHeader = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||||
|
))
|
||||||
|
TableHeader.displayName = "TableHeader"
|
||||||
|
|
||||||
|
const TableBody = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tbody
|
||||||
|
ref={ref}
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableBody.displayName = "TableBody"
|
||||||
|
|
||||||
|
const TableFooter = React.forwardRef<
|
||||||
|
HTMLTableSectionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableSectionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tfoot
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableFooter.displayName = "TableFooter"
|
||||||
|
|
||||||
|
const TableRow = React.forwardRef<
|
||||||
|
HTMLTableRowElement,
|
||||||
|
React.HTMLAttributes<HTMLTableRowElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableRow.displayName = "TableRow"
|
||||||
|
|
||||||
|
const TableHead = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableHead.displayName = "TableHead"
|
||||||
|
|
||||||
|
const TableCell = React.forwardRef<
|
||||||
|
HTMLTableCellElement,
|
||||||
|
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCell.displayName = "TableCell"
|
||||||
|
|
||||||
|
const TableCaption = React.forwardRef<
|
||||||
|
HTMLTableCaptionElement,
|
||||||
|
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<caption
|
||||||
|
ref={ref}
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TableCaption.displayName = "TableCaption"
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root;
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.ComponentProps<"textarea">
|
||||||
|
>(({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import * as ToastPrimitives from '@radix-ui/react-toast';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const ToastProvider = ToastPrimitives.Provider;
|
||||||
|
|
||||||
|
const ToastViewport = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Viewport
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
|
||||||
|
|
||||||
|
const toastVariants = cva(
|
||||||
|
'group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border bg-background text-foreground',
|
||||||
|
destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const Toast = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => {
|
||||||
|
return <ToastPrimitives.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />;
|
||||||
|
});
|
||||||
|
Toast.displayName = ToastPrimitives.Root.displayName;
|
||||||
|
|
||||||
|
const ToastAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
ToastAction.displayName = ToastPrimitives.Action.displayName;
|
||||||
|
|
||||||
|
const ToastClose = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Close
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
toast-close=''
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<X className='h-4 w-4' />
|
||||||
|
</ToastPrimitives.Close>
|
||||||
|
));
|
||||||
|
ToastClose.displayName = ToastPrimitives.Close.displayName;
|
||||||
|
|
||||||
|
const ToastTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Title ref={ref} className={cn('text-sm font-semibold [&+div]:text-xs', className)} {...props} />
|
||||||
|
));
|
||||||
|
ToastTitle.displayName = ToastPrimitives.Title.displayName;
|
||||||
|
|
||||||
|
const ToastDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<ToastPrimitives.Description ref={ref} className={cn('text-sm opacity-90', className)} {...props} />
|
||||||
|
));
|
||||||
|
ToastDescription.displayName = ToastPrimitives.Description.displayName;
|
||||||
|
|
||||||
|
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
|
||||||
|
|
||||||
|
type ToastActionElement = React.ReactElement<typeof ToastAction>;
|
||||||
|
|
||||||
|
export {
|
||||||
|
type ToastProps,
|
||||||
|
type ToastActionElement,
|
||||||
|
ToastProvider,
|
||||||
|
ToastViewport,
|
||||||
|
Toast,
|
||||||
|
ToastTitle,
|
||||||
|
ToastDescription,
|
||||||
|
ToastClose,
|
||||||
|
ToastAction,
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from '@/components/ui/toast';
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const { toasts } = useToast();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastProvider>
|
||||||
|
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||||
|
return (
|
||||||
|
<Toast key={id} {...props}>
|
||||||
|
<div className='grid gap-1'>
|
||||||
|
{title && <ToastTitle>{title}</ToastTitle>}
|
||||||
|
{description && <ToastDescription>{description}</ToastDescription>}
|
||||||
|
</div>
|
||||||
|
{action}
|
||||||
|
<ToastClose />
|
||||||
|
</Toast>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<ToastViewport />
|
||||||
|
</ToastProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import * as React from 'react';
|
||||||
|
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1;
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000;
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
action?: ToastActionElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: 'ADD_TOAST',
|
||||||
|
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||||
|
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||||
|
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes;
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType['ADD_TOAST'];
|
||||||
|
toast: ToasterToast;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType['UPDATE_TOAST'];
|
||||||
|
toast: Partial<ToasterToast>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType['DISMISS_TOAST'];
|
||||||
|
toastId?: ToasterToast['id'];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType['REMOVE_TOAST'];
|
||||||
|
toastId?: ToasterToast['id'];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId);
|
||||||
|
dispatch({
|
||||||
|
type: 'REMOVE_TOAST',
|
||||||
|
toastId: toastId,
|
||||||
|
});
|
||||||
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'ADD_TOAST':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'UPDATE_TOAST':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'DISMISS_TOAST': {
|
||||||
|
const { toastId } = action;
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId);
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'REMOVE_TOAST':
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = [];
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] };
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action);
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, 'id'>;
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId();
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: 'UPDATE_TOAST',
|
||||||
|
toast: { ...props, id },
|
||||||
|
});
|
||||||
|
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'ADD_TOAST',
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState);
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState);
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast };
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { User, Edit, Mail, Calendar, Save, X } from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
surname: string;
|
||||||
|
role: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProfileFormData {
|
||||||
|
name: string;
|
||||||
|
surname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserProfile() {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<ProfileFormData>({
|
||||||
|
name: '',
|
||||||
|
surname: '',
|
||||||
|
});
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const updateFormData = (field: keyof ProfileFormData, value: string) => {
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUserProfile();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchUserProfile = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/users/profile');
|
||||||
|
if (response.ok) {
|
||||||
|
const userData = await response.json();
|
||||||
|
setUser(userData.user);
|
||||||
|
setFormData({
|
||||||
|
name: userData.user.name,
|
||||||
|
surname: userData.user.surname,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch user profile',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user profile:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch user profile',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.name.trim() || !formData.surname.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Name and surname are required',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/users/profile', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: formData.name.trim(),
|
||||||
|
surname: formData.surname.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Profile updated successfully!',
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
await fetchUserProfile(); // Refresh user data
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Failed to update profile',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating profile:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to update profile',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (user) {
|
||||||
|
setFormData({
|
||||||
|
name: user.name,
|
||||||
|
surname: user.surname,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className='p-6'>
|
||||||
|
<div className='flex items-center justify-center'>
|
||||||
|
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900'></div>
|
||||||
|
<p className='ml-2'>Loading profile...</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className='p-6'>
|
||||||
|
<div className='text-center text-gray-500'>
|
||||||
|
<p>Unable to load user profile</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2'>
|
||||||
|
<User className='h-5 w-5' />
|
||||||
|
User Profile
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className='space-y-6'>
|
||||||
|
{/* Profile Information */}
|
||||||
|
<div className='grid grid-cols-1 md:grid-cols-2 gap-6'>
|
||||||
|
{/* Name */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='name'>First Name</Label>
|
||||||
|
{isEditing ? (
|
||||||
|
<Input
|
||||||
|
id='name'
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => updateFormData('name', e.target.value)}
|
||||||
|
placeholder='Enter your first name'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='flex items-center gap-2 p-2 bg-gray-50 rounded'>
|
||||||
|
<span>{user.name}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Surname */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='surname'>Last Name</Label>
|
||||||
|
{isEditing ? (
|
||||||
|
<Input
|
||||||
|
id='surname'
|
||||||
|
value={formData.surname}
|
||||||
|
onChange={(e) => updateFormData('surname', e.target.value)}
|
||||||
|
placeholder='Enter your last name'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='flex items-center gap-2 p-2 bg-gray-50 rounded'>
|
||||||
|
<span>{user.surname}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Email (Read-only) */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='email'>Email Address</Label>
|
||||||
|
<div className='flex items-center gap-2 p-2 bg-gray-100 rounded text-gray-600'>
|
||||||
|
<Mail className='h-4 w-4' />
|
||||||
|
<span>{user.email}</span>
|
||||||
|
<span className='text-xs text-gray-500 ml-auto'>(Read-only)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Member Since */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label>Member Since</Label>
|
||||||
|
<div className='flex items-center gap-2 p-2 bg-gray-50 rounded'>
|
||||||
|
<Calendar className='h-4 w-4' />
|
||||||
|
<span>
|
||||||
|
{new Date(user.createdAt).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className='flex gap-2 pt-4 border-t'>
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<Button onClick={handleSave} disabled={saving} className='flex items-center gap-2'>
|
||||||
|
<Save className='h-4 w-4' />
|
||||||
|
{saving ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={saving}
|
||||||
|
className='flex items-center gap-2'
|
||||||
|
>
|
||||||
|
<X className='h-4 w-4' />
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => setIsEditing(true)} className='flex items-center gap-2'>
|
||||||
|
<Edit className='h-4 w-4' />
|
||||||
|
Edit Profile
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Account Information Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Account Information</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label>Account Type</Label>
|
||||||
|
<div className='p-2 bg-blue-50 rounded text-blue-800 capitalize font-medium'>
|
||||||
|
{user.role}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label>User ID</Label>
|
||||||
|
<div className='p-2 bg-gray-50 rounded text-gray-600 font-mono text-sm'>{user.id}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
tt-booking:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- '3000:3000'
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- DATABASE_URL=/app/data/sqlite.db
|
||||||
|
- NEXTAUTH_URL=http://localhost:3000
|
||||||
|
- NEXTAUTH_SECRET=your-secret-key-here-make-this-very-long-and-random
|
||||||
|
- EMAIL_USER=your-email@gmail.com
|
||||||
|
- EMAIL_PASSWORD=your-app-password-here
|
||||||
|
- ADMIN_EMAIL=admin@example.com
|
||||||
|
- ADMIN_PASSWORD=admin123
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Nginx reverse proxy (optional, for production deployment)
|
||||||
|
nginx:
|
||||||
|
image: nginx:alpine
|
||||||
|
ports:
|
||||||
|
- '80:80'
|
||||||
|
- '443:443'
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- ./ssl:/etc/nginx/ssl:ro
|
||||||
|
depends_on:
|
||||||
|
- tt-booking
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { Config } from 'drizzle-kit';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
schema: './lib/db/schema.ts',
|
||||||
|
out: './lib/db/migrations',
|
||||||
|
driver: 'better-sqlite',
|
||||||
|
dbCredentials: {
|
||||||
|
url: './sqlite.db',
|
||||||
|
},
|
||||||
|
} satisfies Config;
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
// Inspired by react-hot-toast library
|
||||||
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import type { ToastActionElement, ToastProps } from '@/components/ui/toast';
|
||||||
|
|
||||||
|
const TOAST_LIMIT = 1;
|
||||||
|
const TOAST_REMOVE_DELAY = 1000000;
|
||||||
|
|
||||||
|
type ToasterToast = ToastProps & {
|
||||||
|
id: string;
|
||||||
|
title?: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
action?: ToastActionElement;
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionTypes = {
|
||||||
|
ADD_TOAST: 'ADD_TOAST',
|
||||||
|
UPDATE_TOAST: 'UPDATE_TOAST',
|
||||||
|
DISMISS_TOAST: 'DISMISS_TOAST',
|
||||||
|
REMOVE_TOAST: 'REMOVE_TOAST',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
|
||||||
|
function genId() {
|
||||||
|
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||||
|
return count.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionType = typeof actionTypes;
|
||||||
|
|
||||||
|
type Action =
|
||||||
|
| {
|
||||||
|
type: ActionType['ADD_TOAST'];
|
||||||
|
toast: ToasterToast;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType['UPDATE_TOAST'];
|
||||||
|
toast: Partial<ToasterToast>;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType['DISMISS_TOAST'];
|
||||||
|
toastId?: ToasterToast['id'];
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: ActionType['REMOVE_TOAST'];
|
||||||
|
toastId?: ToasterToast['id'];
|
||||||
|
};
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
toasts: ToasterToast[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const addToRemoveQueue = (toastId: string) => {
|
||||||
|
if (toastTimeouts.has(toastId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
toastTimeouts.delete(toastId);
|
||||||
|
dispatch({
|
||||||
|
type: 'REMOVE_TOAST',
|
||||||
|
toastId: toastId,
|
||||||
|
});
|
||||||
|
}, TOAST_REMOVE_DELAY);
|
||||||
|
|
||||||
|
toastTimeouts.set(toastId, timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reducer = (state: State, action: Action): State => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'ADD_TOAST':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'UPDATE_TOAST':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) => (t.id === action.toast.id ? { ...t, ...action.toast } : t)),
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'DISMISS_TOAST': {
|
||||||
|
const { toastId } = action;
|
||||||
|
|
||||||
|
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||||
|
// but I'll keep it here for simplicity
|
||||||
|
if (toastId) {
|
||||||
|
addToRemoveQueue(toastId);
|
||||||
|
} else {
|
||||||
|
state.toasts.forEach((toast) => {
|
||||||
|
addToRemoveQueue(toast.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.map((t) =>
|
||||||
|
t.id === toastId || toastId === undefined
|
||||||
|
? {
|
||||||
|
...t,
|
||||||
|
open: false,
|
||||||
|
}
|
||||||
|
: t
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'REMOVE_TOAST':
|
||||||
|
if (action.toastId === undefined) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners: Array<(state: State) => void> = [];
|
||||||
|
|
||||||
|
let memoryState: State = { toasts: [] };
|
||||||
|
|
||||||
|
function dispatch(action: Action) {
|
||||||
|
memoryState = reducer(memoryState, action);
|
||||||
|
listeners.forEach((listener) => {
|
||||||
|
listener(memoryState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Toast = Omit<ToasterToast, 'id'>;
|
||||||
|
|
||||||
|
function toast({ ...props }: Toast) {
|
||||||
|
const id = genId();
|
||||||
|
|
||||||
|
const update = (props: ToasterToast) =>
|
||||||
|
dispatch({
|
||||||
|
type: 'UPDATE_TOAST',
|
||||||
|
toast: { ...props, id },
|
||||||
|
});
|
||||||
|
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'ADD_TOAST',
|
||||||
|
toast: {
|
||||||
|
...props,
|
||||||
|
id,
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open) => {
|
||||||
|
if (!open) dismiss();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: id,
|
||||||
|
dismiss,
|
||||||
|
update,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useToast() {
|
||||||
|
const [state, setState] = React.useState<State>(memoryState);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
listeners.push(setState);
|
||||||
|
return () => {
|
||||||
|
const index = listeners.indexOf(setState);
|
||||||
|
if (index > -1) {
|
||||||
|
listeners.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
toast,
|
||||||
|
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { useToast, toast };
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { activityLogs } from '@/lib/db/schema';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
export interface ActivityLogData {
|
||||||
|
userId?: string | null;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId?: string;
|
||||||
|
details?: any;
|
||||||
|
request?: NextRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logActivity({ userId, action, entityType, entityId, details, request }: ActivityLogData) {
|
||||||
|
try {
|
||||||
|
// Extract IP and User Agent from request if provided
|
||||||
|
let ipAddress: string | null = null;
|
||||||
|
let userAgent: string | null = null;
|
||||||
|
|
||||||
|
if (request) {
|
||||||
|
// Try to get real IP address
|
||||||
|
ipAddress =
|
||||||
|
request.headers.get('x-forwarded-for')?.split(',')[0] ||
|
||||||
|
request.headers.get('x-real-ip') ||
|
||||||
|
request.headers.get('cf-connecting-ip') ||
|
||||||
|
'127.0.0.1';
|
||||||
|
|
||||||
|
userAgent = request.headers.get('user-agent');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(activityLogs).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
userId,
|
||||||
|
action,
|
||||||
|
entityType,
|
||||||
|
entityId,
|
||||||
|
details: details ? JSON.stringify(details) : null,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
createdAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`Activity logged: ${action} on ${entityType}${entityId ? ` (${entityId})` : ''} by user ${
|
||||||
|
userId || 'anonymous'
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to log activity:', error);
|
||||||
|
// Don't throw error to avoid breaking the main request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Predefined action types for consistency
|
||||||
|
export const ACTIONS = {
|
||||||
|
// User actions
|
||||||
|
USER_LOGIN: 'login',
|
||||||
|
USER_LOGOUT: 'logout',
|
||||||
|
USER_REGISTER: 'register',
|
||||||
|
USER_CREATE: 'create_user',
|
||||||
|
USER_UPDATE: 'update_user',
|
||||||
|
USER_DELETE: 'delete_user',
|
||||||
|
|
||||||
|
// Booking actions
|
||||||
|
BOOKING_CREATE: 'create_booking',
|
||||||
|
BOOKING_UPDATE: 'update_booking',
|
||||||
|
BOOKING_CANCEL: 'cancel_booking',
|
||||||
|
BOOKING_DELETE: 'delete_booking',
|
||||||
|
|
||||||
|
// Court actions
|
||||||
|
COURT_CREATE: 'create_court',
|
||||||
|
COURT_UPDATE: 'update_court',
|
||||||
|
COURT_DELETE: 'delete_court',
|
||||||
|
|
||||||
|
// Announcement actions
|
||||||
|
ANNOUNCEMENT_CREATE: 'create_announcement',
|
||||||
|
ANNOUNCEMENT_UPDATE: 'update_announcement',
|
||||||
|
ANNOUNCEMENT_DELETE: 'delete_announcement',
|
||||||
|
|
||||||
|
// Settings actions
|
||||||
|
SETTINGS_UPDATE: 'update_settings',
|
||||||
|
|
||||||
|
// System actions
|
||||||
|
SYSTEM_START: 'system_start',
|
||||||
|
SYSTEM_ERROR: 'system_error',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const ENTITY_TYPES = {
|
||||||
|
USER: 'user',
|
||||||
|
BOOKING: 'booking',
|
||||||
|
COURT: 'court',
|
||||||
|
ANNOUNCEMENT: 'announcement',
|
||||||
|
SETTINGS: 'settings',
|
||||||
|
SYSTEM: 'system',
|
||||||
|
} as const;
|
||||||
+48
@@ -0,0 +1,48 @@
|
|||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { users } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { generateId } from '@/lib/utils';
|
||||||
|
|
||||||
|
export async function hashPassword(password: string): Promise<string> {
|
||||||
|
return bcrypt.hash(password, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifyPassword(password: string, hashedPassword: string): Promise<boolean> {
|
||||||
|
return bcrypt.compare(password, hashedPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createUser(data: {
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
surname: string;
|
||||||
|
password: string;
|
||||||
|
role?: 'user' | 'admin';
|
||||||
|
}) {
|
||||||
|
const hashedPassword = await hashPassword(data.password);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
id: generateId(),
|
||||||
|
email: data.email.toLowerCase(),
|
||||||
|
name: data.name,
|
||||||
|
surname: data.surname,
|
||||||
|
password: hashedPassword,
|
||||||
|
role: data.role || 'user',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
const [user] = await db.insert(users).values(newUser).returning();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserByEmail(email: string) {
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.email, email.toLowerCase()));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserById(id: string) {
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.id, id));
|
||||||
|
return user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
const sqlite = new Database('./sqlite.db');
|
||||||
|
export const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
|
// Run migrations on startup
|
||||||
|
try {
|
||||||
|
migrate(db, { migrationsFolder: './lib/db/migrations' });
|
||||||
|
console.log('Database migrations completed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Database migration failed:', error);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":"5","dialect":"sqlite","entries":[]}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { sqliteTable, text, integer, real } from 'drizzle-orm/sqlite-core';
|
||||||
|
import { createInsertSchema, createSelectSchema } from 'drizzle-zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Users table
|
||||||
|
export const users = sqliteTable('users', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
surname: text('surname').notNull(),
|
||||||
|
password: text('password').notNull(),
|
||||||
|
role: text('role', { enum: ['user', 'admin'] })
|
||||||
|
.notNull()
|
||||||
|
.default('user'),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Courts table
|
||||||
|
export const courts = sqliteTable('courts', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
name: text('name').notNull(),
|
||||||
|
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Settings table for admin configuration
|
||||||
|
export const settings = sqliteTable('settings', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
key: text('key').notNull().unique(),
|
||||||
|
value: text('value').notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Time slots configuration
|
||||||
|
export const timeSlots = sqliteTable('time_slots', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
dayOfWeek: integer('day_of_week').notNull(), // 0 = Sunday, 1 = Monday, etc.
|
||||||
|
startTime: text('start_time').notNull(), // Format: "HH:MM"
|
||||||
|
endTime: text('end_time').notNull(), // Format: "HH:MM"
|
||||||
|
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bookings table
|
||||||
|
export const bookings = sqliteTable('bookings', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
courtId: text('court_id')
|
||||||
|
.notNull()
|
||||||
|
.references(() => courts.id, { onDelete: 'cascade' }),
|
||||||
|
date: text('date').notNull(), // Format: "YYYY-MM-DD"
|
||||||
|
startTime: text('start_time').notNull(), // Format: "HH:MM"
|
||||||
|
endTime: text('end_time').notNull(), // Format: "HH:MM"
|
||||||
|
status: text('status', { enum: ['active', 'cancelled'] })
|
||||||
|
.notNull()
|
||||||
|
.default('active'),
|
||||||
|
notes: text('notes'),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Announcements table
|
||||||
|
export const announcements = sqliteTable('announcements', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
title: text('title').notNull(),
|
||||||
|
content: text('content').notNull(),
|
||||||
|
isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true),
|
||||||
|
priority: text('priority', { enum: ['low', 'medium', 'high'] })
|
||||||
|
.notNull()
|
||||||
|
.default('medium'),
|
||||||
|
expiresAt: integer('expires_at', { mode: 'timestamp' }),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activity logs for admin transparency
|
||||||
|
export const activityLogs = sqliteTable('activity_logs', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
userId: text('user_id').references(() => users.id, { onDelete: 'set null' }),
|
||||||
|
action: text('action').notNull(),
|
||||||
|
entityType: text('entity_type').notNull(),
|
||||||
|
entityId: text('entity_id'),
|
||||||
|
details: text('details'), // JSON string
|
||||||
|
ipAddress: text('ip_address'),
|
||||||
|
userAgent: text('user_agent'),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Zod schemas for validation
|
||||||
|
export const insertUserSchema = createInsertSchema(users);
|
||||||
|
export const selectUserSchema = createSelectSchema(users);
|
||||||
|
export const insertCourtSchema = createInsertSchema(courts);
|
||||||
|
export const selectCourtSchema = createSelectSchema(courts);
|
||||||
|
export const insertBookingSchema = createInsertSchema(bookings);
|
||||||
|
export const selectBookingSchema = createSelectSchema(bookings);
|
||||||
|
export const insertAnnouncementSchema = createInsertSchema(announcements);
|
||||||
|
export const selectAnnouncementSchema = createSelectSchema(announcements);
|
||||||
|
export const insertTimeSlotSchema = createInsertSchema(timeSlots);
|
||||||
|
export const selectTimeSlotSchema = createSelectSchema(timeSlots);
|
||||||
|
export const insertSettingSchema = createInsertSchema(settings);
|
||||||
|
export const selectSettingSchema = createSelectSchema(settings);
|
||||||
|
export const insertActivityLogSchema = createInsertSchema(activityLogs);
|
||||||
|
export const selectActivityLogSchema = createSelectSchema(activityLogs);
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type User = typeof users.$inferSelect;
|
||||||
|
export type NewUser = typeof users.$inferInsert;
|
||||||
|
export type Court = typeof courts.$inferSelect;
|
||||||
|
export type NewCourt = typeof courts.$inferInsert;
|
||||||
|
export type Booking = typeof bookings.$inferSelect;
|
||||||
|
export type NewBooking = typeof bookings.$inferInsert;
|
||||||
|
export type Announcement = typeof announcements.$inferSelect;
|
||||||
|
export type NewAnnouncement = typeof announcements.$inferInsert;
|
||||||
|
export type TimeSlot = typeof timeSlots.$inferSelect;
|
||||||
|
export type NewTimeSlot = typeof timeSlots.$inferInsert;
|
||||||
|
export type Setting = typeof settings.$inferSelect;
|
||||||
|
export type NewSetting = typeof settings.$inferInsert;
|
||||||
|
export type ActivityLog = typeof activityLogs.$inferSelect;
|
||||||
|
export type NewActivityLog = typeof activityLogs.$inferInsert;
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
|
||||||
|
interface EmailOptions {
|
||||||
|
to: string;
|
||||||
|
subject: string;
|
||||||
|
html: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create reusable transporter object using SMTP transport
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
service: 'gmail',
|
||||||
|
auth: {
|
||||||
|
user: process.env.EMAIL_USER,
|
||||||
|
pass: process.env.EMAIL_PASSWORD, // Use App Password for Gmail
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function sendEmail({ to, subject, html }: EmailOptions) {
|
||||||
|
try {
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: `"Table Tennis Booking" <${process.env.EMAIL_USER}>`,
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
html,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Email sent: %s', info.messageId);
|
||||||
|
return { success: true, messageId: info.messageId };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sending email:', error);
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateBookingConfirmationEmail(booking: {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
courtName: string;
|
||||||
|
userName: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
subject: 'Booking Confirmation - Table Tennis Court',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #333;">Booking Confirmed!</h2>
|
||||||
|
<p>Hello ${booking.userName},</p>
|
||||||
|
<p>Your table tennis court booking has been confirmed:</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0;">Booking Details</h3>
|
||||||
|
<p><strong>Booking ID:</strong> ${booking.id}</p>
|
||||||
|
<p><strong>Date:</strong> ${booking.date}</p>
|
||||||
|
<p><strong>Time:</strong> ${booking.startTime} - ${booking.endTime}</p>
|
||||||
|
<p><strong>Court:</strong> ${booking.courtName}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>Please arrive 5 minutes before your booking time. If you need to cancel or modify your booking, please log in to your account.</p>
|
||||||
|
|
||||||
|
<p>Thank you for choosing our table tennis facility!</p>
|
||||||
|
|
||||||
|
<hr style="margin: 30px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">
|
||||||
|
This is an automated email. Please do not reply to this message.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateBookingCancellationEmail(booking: {
|
||||||
|
id: string;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
courtName: string;
|
||||||
|
userName: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
subject: 'Booking Cancelled - Table Tennis Court',
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #d32f2f;">Booking Cancelled</h2>
|
||||||
|
<p>Hello ${booking.userName},</p>
|
||||||
|
<p>Your table tennis court booking has been cancelled:</p>
|
||||||
|
|
||||||
|
<div style="background-color: #f5f5f5; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<h3 style="margin-top: 0;">Cancelled Booking Details</h3>
|
||||||
|
<p><strong>Booking ID:</strong> ${booking.id}</p>
|
||||||
|
<p><strong>Date:</strong> ${booking.date}</p>
|
||||||
|
<p><strong>Time:</strong> ${booking.startTime} - ${booking.endTime}</p>
|
||||||
|
<p><strong>Court:</strong> ${booking.courtName}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>You can make a new booking anytime through our booking system.</p>
|
||||||
|
|
||||||
|
<p>Thank you for using our table tennis facility!</p>
|
||||||
|
|
||||||
|
<hr style="margin: 30px 0;">
|
||||||
|
<p style="font-size: 12px; color: #666;">
|
||||||
|
This is an automated email. Please do not reply to this message.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
};
|
||||||
|
}
|
||||||
+101
@@ -0,0 +1,101 @@
|
|||||||
|
import { SignJWT, jwtVerify } from 'jose';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
|
||||||
|
const secretKey = process.env.NEXTAUTH_SECRET;
|
||||||
|
const encodedKey = new TextEncoder().encode(secretKey);
|
||||||
|
|
||||||
|
export interface SessionPayload {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
role: 'user' | 'admin';
|
||||||
|
expiresAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encrypt(payload: SessionPayload) {
|
||||||
|
return new SignJWT({
|
||||||
|
userId: payload.userId,
|
||||||
|
email: payload.email,
|
||||||
|
role: payload.role,
|
||||||
|
expiresAt: payload.expiresAt.getTime(),
|
||||||
|
})
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('7d')
|
||||||
|
.sign(encodedKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decrypt(session: string | undefined = '') {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(session, encodedKey, {
|
||||||
|
algorithms: ['HS256'],
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
userId: payload.userId as string,
|
||||||
|
email: payload.email as string,
|
||||||
|
role: payload.role as 'user' | 'admin',
|
||||||
|
expiresAt: new Date(payload.expiresAt as number),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Failed to verify session');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSession(payload: Omit<SessionPayload, 'expiresAt'>) {
|
||||||
|
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const session = await encrypt({ ...payload, expiresAt });
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.set('session', session, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
expires: expiresAt,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSession() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const session = cookieStore.get('session')?.value;
|
||||||
|
const payload = await decrypt(session);
|
||||||
|
|
||||||
|
if (!session || !payload) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const newSession = await encrypt({ ...payload, expiresAt: expires });
|
||||||
|
|
||||||
|
cookieStore.set('session', newSession, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
expires: expires,
|
||||||
|
sameSite: 'lax',
|
||||||
|
path: '/',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSession() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
cookieStore.delete('session');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSession() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const session = cookieStore.get('session')?.value;
|
||||||
|
return await decrypt(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySession() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const session = cookieStore.get('session')?.value;
|
||||||
|
const payload = await decrypt(session);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return { isAuth: false, userId: null, role: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isAuth: true, userId: payload.userId, role: payload.role };
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { type ClassValue, clsx } from 'clsx';
|
||||||
|
import { twMerge } from 'tailwind-merge';
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateId(): string {
|
||||||
|
return Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(time: string): string {
|
||||||
|
const [hours, minutes] = time.split(':');
|
||||||
|
const hour = parseInt(hours);
|
||||||
|
const ampm = hour >= 12 ? 'PM' : 'AM';
|
||||||
|
const displayHour = hour % 12 || 12;
|
||||||
|
return `${displayHour}:${minutes} ${ampm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: string): string {
|
||||||
|
return new Date(date).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWithinBookingWindow(date: string): boolean {
|
||||||
|
const bookingDate = new Date(date);
|
||||||
|
const today = new Date();
|
||||||
|
const maxDate = new Date();
|
||||||
|
maxDate.setDate(today.getDate() + 6); // 7 days including today
|
||||||
|
|
||||||
|
// Reset time to start of day for comparison
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
maxDate.setHours(23, 59, 59, 999);
|
||||||
|
bookingDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
return bookingDate >= today && bookingDate <= maxDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeekDays(): Array<{ value: number; label: string }> {
|
||||||
|
return [
|
||||||
|
{ value: 0, label: 'Sunday' },
|
||||||
|
{ value: 1, label: 'Monday' },
|
||||||
|
{ value: 2, label: 'Tuesday' },
|
||||||
|
{ value: 3, label: 'Wednesday' },
|
||||||
|
{ value: 4, label: 'Thursday' },
|
||||||
|
{ value: 5, label: 'Friday' },
|
||||||
|
{ value: 6, label: 'Saturday' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateTimeSlots(startHour: number, endHour: number): string[] {
|
||||||
|
const slots = [];
|
||||||
|
for (let hour = startHour; hour < endHour; hour++) {
|
||||||
|
const hourStr = hour < 10 ? '0' + hour : hour.toString();
|
||||||
|
slots.push(`${hourStr}:00`);
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { decrypt } from '@/lib/session';
|
||||||
|
|
||||||
|
// Protected routes that require authentication
|
||||||
|
const protectedRoutes = ['/dashboard', '/admin', '/bookings', '/profile'];
|
||||||
|
// Admin routes that require admin role
|
||||||
|
const adminRoutes = ['/admin'];
|
||||||
|
// Auth routes that should redirect if already authenticated
|
||||||
|
const authRoutes = ['/login', '/register'];
|
||||||
|
|
||||||
|
export default async function middleware(req: NextRequest) {
|
||||||
|
const path = req.nextUrl.pathname;
|
||||||
|
const isProtectedRoute = protectedRoutes.some((route) => path.startsWith(route));
|
||||||
|
const isAdminRoute = adminRoutes.some((route) => path.startsWith(route));
|
||||||
|
const isAuthRoute = authRoutes.includes(path);
|
||||||
|
|
||||||
|
const cookie = req.cookies.get('session')?.value;
|
||||||
|
const session = await decrypt(cookie);
|
||||||
|
|
||||||
|
// Redirect to login if accessing protected route without session
|
||||||
|
if (isProtectedRoute && !session?.userId) {
|
||||||
|
return NextResponse.redirect(new URL('/login', req.nextUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to dashboard if accessing auth routes while authenticated
|
||||||
|
if (isAuthRoute && session?.userId) {
|
||||||
|
return NextResponse.redirect(new URL('/dashboard', req.nextUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redirect to dashboard if accessing admin route without admin role
|
||||||
|
if (isAdminRoute && session?.role !== 'admin') {
|
||||||
|
return NextResponse.redirect(new URL('/dashboard', req.nextUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
|
||||||
|
};
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
images: {
|
||||||
|
remotePatterns: [
|
||||||
|
{
|
||||||
|
protocol: 'https',
|
||||||
|
hostname: '**',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
+78
@@ -0,0 +1,78 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
upstream app {
|
||||||
|
server tt-booking:3000;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rate limiting
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:;" always;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
# Redirect HTTP to HTTPS
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
# SSL configuration
|
||||||
|
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||||
|
ssl_prefer_server_ciphers off;
|
||||||
|
|
||||||
|
# API rate limiting
|
||||||
|
location /api/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
proxy_pass http://app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Login rate limiting
|
||||||
|
location /api/auth/login {
|
||||||
|
limit_req zone=login burst=3 nodelay;
|
||||||
|
proxy_pass http://app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Static files and general requests
|
||||||
|
location / {
|
||||||
|
proxy_pass http://app;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+12070
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
|||||||
|
{
|
||||||
|
"name": "tt-booking",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"db:push": "drizzle-kit push:sqlite",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:setup": "tsx scripts/setup-db.ts",
|
||||||
|
"postinstall": "npm run db:push"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
|
"@radix-ui/react-accordion": "^1.1.2",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
|
"@radix-ui/react-popover": "^1.0.7",
|
||||||
|
"@radix-ui/react-progress": "^1.0.3",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||||
|
"@radix-ui/react-select": "^2.2.6",
|
||||||
|
"@radix-ui/react-separator": "^1.0.3",
|
||||||
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
|
"@radix-ui/react-switch": "^1.2.6",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
|
"@radix-ui/react-toast": "^1.2.15",
|
||||||
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"better-sqlite3": "^9.2.2",
|
||||||
|
"class-variance-authority": "^0.7.0",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"drizzle-orm": "^0.29.1",
|
||||||
|
"drizzle-zod": "^0.5.1",
|
||||||
|
"jose": "^6.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"lucide-react": "^0.544.0",
|
||||||
|
"next": "^15.5.3",
|
||||||
|
"next-themes": "^0.2.1",
|
||||||
|
"nodemailer": "^6.9.7",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-day-picker": "^9.10.0",
|
||||||
|
"react-dom": "^19.1.1",
|
||||||
|
"react-hook-form": "^7.62.0",
|
||||||
|
"tailwind-merge": "^2.1.0",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"zod": "^3.25.76"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/better-sqlite3": "^7.6.8",
|
||||||
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/nodemailer": "^6.4.14",
|
||||||
|
"@types/react": "^18",
|
||||||
|
"@types/react-dom": "^18",
|
||||||
|
"autoprefixer": "^10.0.1",
|
||||||
|
"drizzle-kit": "^0.20.6",
|
||||||
|
"eslint": "^8",
|
||||||
|
"eslint-config-next": "^15.5.3",
|
||||||
|
"postcss": "^8",
|
||||||
|
"tailwindcss": "^3.3.0",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
async function setupDatabase() {
|
||||||
|
const db = new Database('sqlite.db');
|
||||||
|
|
||||||
|
// Enable foreign keys
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
// Create tables
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
surname TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS courts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS time_slots (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
start_time TEXT NOT NULL,
|
||||||
|
end_time TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bookings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
court_id TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
time_slot_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'confirmed' CHECK (status IN ('confirmed', 'cancelled', 'pending')),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY (court_id) REFERENCES courts(id),
|
||||||
|
FOREIGN KEY (time_slot_id) REFERENCES time_slots(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS announcements (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('Tables created successfully!');
|
||||||
|
|
||||||
|
// Insert default admin user
|
||||||
|
const now = Date.now();
|
||||||
|
const adminPassword = await bcrypt.hash('admin123', 10);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO users (id, email, name, surname, password, role, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
stmt.run(
|
||||||
|
'admin-' + crypto.randomUUID(),
|
||||||
|
'admin@ttbooking.com',
|
||||||
|
'Admin',
|
||||||
|
'User',
|
||||||
|
adminPassword,
|
||||||
|
'admin',
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('Admin user created: admin@ttbooking.com / admin123');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Admin user might already exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert default courts
|
||||||
|
try {
|
||||||
|
const courtStmt = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO courts (id, name, is_active, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
courtStmt.run('court-1', 'Court 1', 1, now, now);
|
||||||
|
courtStmt.run('court-2', 'Court 2', 1, now, now);
|
||||||
|
|
||||||
|
console.log('Default courts created');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Courts might already exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert time slots
|
||||||
|
try {
|
||||||
|
const timeSlotStmt = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO time_slots (id, start_time, end_time, is_active, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const timeSlots = [
|
||||||
|
['09:00', '10:00'],
|
||||||
|
['10:00', '11:00'],
|
||||||
|
['11:00', '12:00'],
|
||||||
|
['12:00', '13:00'],
|
||||||
|
['13:00', '14:00'],
|
||||||
|
['14:00', '15:00'],
|
||||||
|
['15:00', '16:00'],
|
||||||
|
['16:00', '17:00'],
|
||||||
|
['17:00', '18:00'],
|
||||||
|
['18:00', '19:00'],
|
||||||
|
['19:00', '20:00'],
|
||||||
|
['20:00', '21:00'],
|
||||||
|
];
|
||||||
|
|
||||||
|
timeSlots.forEach(([start, end], index) => {
|
||||||
|
timeSlotStmt.run(`slot-${index + 1}`, start, end, 1, now, now);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Time slots created');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Time slots might already exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert default settings
|
||||||
|
try {
|
||||||
|
const settingsStmt = db.prepare(`
|
||||||
|
INSERT OR IGNORE INTO settings (id, key, value, description, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
settingsStmt.run('setting-1', 'booking_advance_days', '7', 'Days in advance users can book', now, now);
|
||||||
|
settingsStmt.run('setting-2', 'max_bookings_per_user', '3', 'Maximum bookings per user per week', now, now);
|
||||||
|
settingsStmt.run('setting-3', 'booking_duration', '60', 'Booking duration in minutes', now, now);
|
||||||
|
|
||||||
|
console.log('Default settings created');
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Settings might already exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
console.log('Database setup completed!');
|
||||||
|
}
|
||||||
|
|
||||||
|
setupDatabase().catch(console.error);
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import * as schema from '../lib/db/schema';
|
||||||
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const sqlite = new Database('./sqlite.db');
|
||||||
|
const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
|
async function resetDatabase() {
|
||||||
|
console.log('Resetting database...');
|
||||||
|
|
||||||
|
// Drop all tables
|
||||||
|
const tables = [
|
||||||
|
'activity_logs',
|
||||||
|
'bookings',
|
||||||
|
'announcements',
|
||||||
|
'time_slots',
|
||||||
|
'settings',
|
||||||
|
'courts',
|
||||||
|
'users',
|
||||||
|
'__drizzle_migrations',
|
||||||
|
'__old_push_courts',
|
||||||
|
'__old_push_users',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const table of tables) {
|
||||||
|
try {
|
||||||
|
await db.run(sql.raw(`DROP TABLE IF EXISTS ${table}`));
|
||||||
|
console.log(`Dropped table: ${table}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Table ${table} doesn't exist or error dropping:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create all tables with current schema
|
||||||
|
console.log('Creating tables...');
|
||||||
|
|
||||||
|
// Users table
|
||||||
|
await db.run(sql`
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
surname TEXT NOT NULL,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Courts table
|
||||||
|
await db.run(sql`
|
||||||
|
CREATE TABLE courts (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Settings table
|
||||||
|
await db.run(sql`
|
||||||
|
CREATE TABLE settings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
key TEXT NOT NULL UNIQUE,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Time slots table
|
||||||
|
await db.run(sql`
|
||||||
|
CREATE TABLE time_slots (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
day_of_week INTEGER NOT NULL,
|
||||||
|
start_time TEXT NOT NULL,
|
||||||
|
end_time TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Bookings table
|
||||||
|
await db.run(sql`
|
||||||
|
CREATE TABLE bookings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
court_id TEXT NOT NULL REFERENCES courts(id) ON DELETE CASCADE,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
start_time TEXT NOT NULL,
|
||||||
|
end_time TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'cancelled')),
|
||||||
|
notes TEXT,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Announcements table with all required columns
|
||||||
|
await db.run(sql`
|
||||||
|
CREATE TABLE announcements (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high')),
|
||||||
|
expires_at INTEGER,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Activity logs table
|
||||||
|
await db.run(sql`
|
||||||
|
CREATE TABLE activity_logs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
entity_type TEXT NOT NULL,
|
||||||
|
entity_id TEXT,
|
||||||
|
details TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
user_agent TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('All tables created successfully!');
|
||||||
|
|
||||||
|
// Insert seed data
|
||||||
|
console.log('Inserting seed data...');
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
const hashedPassword = await bcrypt.hash('admin123', 12);
|
||||||
|
await db.insert(schema.users).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
email: 'admin@ttbooking.com',
|
||||||
|
name: 'Admin',
|
||||||
|
surname: 'User',
|
||||||
|
password: hashedPassword,
|
||||||
|
role: 'admin',
|
||||||
|
createdAt: new Date(now),
|
||||||
|
updatedAt: new Date(now),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
const testPassword = await bcrypt.hash('password123', 12);
|
||||||
|
await db.insert(schema.users).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
email: 'user@test.com',
|
||||||
|
name: 'Test',
|
||||||
|
surname: 'User',
|
||||||
|
password: testPassword,
|
||||||
|
role: 'user',
|
||||||
|
createdAt: new Date(now),
|
||||||
|
updatedAt: new Date(now),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create courts
|
||||||
|
const court1Id = randomUUID();
|
||||||
|
const court2Id = randomUUID();
|
||||||
|
|
||||||
|
await db.insert(schema.courts).values([
|
||||||
|
{
|
||||||
|
id: court1Id,
|
||||||
|
name: 'Court 1',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(now),
|
||||||
|
updatedAt: new Date(now),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: court2Id,
|
||||||
|
name: 'Court 2',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(now),
|
||||||
|
updatedAt: new Date(now),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Insert default settings
|
||||||
|
await db.insert(schema.settings).values([
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
key: 'booking_window_days',
|
||||||
|
value: '7',
|
||||||
|
updatedAt: new Date(now),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
key: 'max_booking_duration_hours',
|
||||||
|
value: '2',
|
||||||
|
updatedAt: new Date(now),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
key: 'min_booking_duration_minutes',
|
||||||
|
value: '30',
|
||||||
|
updatedAt: new Date(now),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
key: 'booking_start_time',
|
||||||
|
value: '08:00',
|
||||||
|
updatedAt: new Date(now),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
key: 'booking_end_time',
|
||||||
|
value: '22:00',
|
||||||
|
updatedAt: new Date(now),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
key: 'allow_weekend_bookings',
|
||||||
|
value: 'true',
|
||||||
|
updatedAt: new Date(now),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create time slots for all days (8 AM to 10 PM)
|
||||||
|
const timeSlotData = [];
|
||||||
|
for (let day = 0; day < 7; day++) {
|
||||||
|
for (let hour = 8; hour < 22; hour += 2) {
|
||||||
|
timeSlotData.push({
|
||||||
|
id: randomUUID(),
|
||||||
|
dayOfWeek: day,
|
||||||
|
startTime: `${hour.toString().padStart(2, '0')}:00`,
|
||||||
|
endTime: `${(hour + 2).toString().padStart(2, '0')}:00`,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(now),
|
||||||
|
updatedAt: new Date(now),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(schema.timeSlots).values(timeSlotData);
|
||||||
|
|
||||||
|
// Create sample announcement
|
||||||
|
await db.insert(schema.announcements).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
title: 'Welcome to Table Tennis Booking System',
|
||||||
|
content: 'Book your court times easily and manage your games efficiently.',
|
||||||
|
isActive: true,
|
||||||
|
priority: 'high',
|
||||||
|
expiresAt: null,
|
||||||
|
createdAt: new Date(now),
|
||||||
|
updatedAt: new Date(now),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Seed data inserted successfully!');
|
||||||
|
console.log('Database reset complete!');
|
||||||
|
|
||||||
|
sqlite.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
resetDatabase().catch(console.error);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { announcements } from '@/lib/db/schema';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
async function seedAnnouncements() {
|
||||||
|
try {
|
||||||
|
const testAnnouncements = [
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
title: 'Welcome to the New Booking System!',
|
||||||
|
content:
|
||||||
|
'We have upgraded our table tennis booking system with new features including mobile support, partner booking, and booking management. Enjoy your games!',
|
||||||
|
priority: 'high' as const,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
title: 'Court Maintenance Schedule',
|
||||||
|
content:
|
||||||
|
'Court 2 will be under maintenance this Friday from 2 PM to 4 PM. Please plan your bookings accordingly.',
|
||||||
|
priority: 'medium' as const,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
title: 'New Partnership Feature',
|
||||||
|
content:
|
||||||
|
'You can now specify your playing partner when making a booking. This helps other players know who will be using the court.',
|
||||||
|
priority: 'low' as const,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const announcement of testAnnouncements) {
|
||||||
|
await db.insert(announcements).values(announcement);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Test announcements created successfully!');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating test announcements:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedAnnouncements();
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { db } from '../lib/db';
|
||||||
|
import { users, courts as courtsTable, bookings, announcements, activityLogs } from '../lib/db/schema';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
async function seedData() {
|
||||||
|
try {
|
||||||
|
console.log('Starting data seeding...');
|
||||||
|
|
||||||
|
// Get existing users to add sample bookings and activities
|
||||||
|
const existingUsers = await db.select().from(users);
|
||||||
|
|
||||||
|
if (existingUsers.length < 2) {
|
||||||
|
console.log('Not enough users found. Please run the reset-database script first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminUser = existingUsers.find((u) => u.role === 'admin');
|
||||||
|
const regularUser = existingUsers.find((u) => u.role === 'user');
|
||||||
|
const courts = await db.select().from(courtsTable);
|
||||||
|
|
||||||
|
if (!adminUser || !regularUser || courts.length === 0) {
|
||||||
|
console.log('Missing admin user, regular user, or courts. Please run reset-database first.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const today = now.toISOString().split('T')[0];
|
||||||
|
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Add some sample bookings
|
||||||
|
console.log('Creating sample bookings...');
|
||||||
|
|
||||||
|
const sampleBookings = [
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: regularUser.id,
|
||||||
|
courtId: courts[0].id,
|
||||||
|
date: today,
|
||||||
|
startTime: '19:00',
|
||||||
|
endTime: '20:00',
|
||||||
|
status: 'active' as const,
|
||||||
|
notes: 'Regular evening practice session',
|
||||||
|
createdAt: new Date(now.getTime() - 2 * 60 * 60 * 1000), // 2 hours ago
|
||||||
|
updatedAt: new Date(now.getTime() - 2 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: regularUser.id,
|
||||||
|
courtId: courts[1] ? courts[1].id : courts[0].id,
|
||||||
|
date: tomorrow,
|
||||||
|
startTime: '20:00',
|
||||||
|
endTime: '21:00',
|
||||||
|
status: 'active' as const,
|
||||||
|
notes: 'Tournament preparation',
|
||||||
|
createdAt: new Date(now.getTime() - 1 * 60 * 60 * 1000), // 1 hour ago
|
||||||
|
updatedAt: new Date(now.getTime() - 1 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.insert(bookings).values(sampleBookings);
|
||||||
|
|
||||||
|
// Add sample activity logs
|
||||||
|
console.log('Creating sample activity logs...');
|
||||||
|
|
||||||
|
const sampleLogs = [
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: adminUser.id,
|
||||||
|
action: 'login',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: adminUser.id,
|
||||||
|
details: JSON.stringify({
|
||||||
|
email: adminUser.email,
|
||||||
|
role: adminUser.role,
|
||||||
|
loginMethod: 'password',
|
||||||
|
}),
|
||||||
|
ipAddress: '192.168.1.100',
|
||||||
|
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||||
|
createdAt: new Date(now.getTime() - 3 * 60 * 60 * 1000), // 3 hours ago
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: regularUser.id,
|
||||||
|
action: 'create_booking',
|
||||||
|
entityType: 'booking',
|
||||||
|
entityId: sampleBookings[0].id,
|
||||||
|
details: JSON.stringify({
|
||||||
|
courtId: courts[0].id,
|
||||||
|
courtName: courts[0].name,
|
||||||
|
date: today,
|
||||||
|
startTime: '19:00',
|
||||||
|
endTime: '20:00',
|
||||||
|
}),
|
||||||
|
ipAddress: '192.168.1.101',
|
||||||
|
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||||
|
createdAt: new Date(now.getTime() - 2 * 60 * 60 * 1000), // 2 hours ago
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: regularUser.id,
|
||||||
|
action: 'login',
|
||||||
|
entityType: 'user',
|
||||||
|
entityId: regularUser.id,
|
||||||
|
details: JSON.stringify({
|
||||||
|
email: regularUser.email,
|
||||||
|
role: regularUser.role,
|
||||||
|
loginMethod: 'password',
|
||||||
|
}),
|
||||||
|
ipAddress: '192.168.1.101',
|
||||||
|
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||||
|
createdAt: new Date(now.getTime() - 2.5 * 60 * 60 * 1000), // 2.5 hours ago
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: adminUser.id,
|
||||||
|
action: 'create_announcement',
|
||||||
|
entityType: 'announcement',
|
||||||
|
entityId: null,
|
||||||
|
details: JSON.stringify({
|
||||||
|
title: 'System Maintenance',
|
||||||
|
priority: 'high',
|
||||||
|
action: 'created_via_seed',
|
||||||
|
}),
|
||||||
|
ipAddress: '192.168.1.100',
|
||||||
|
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||||
|
createdAt: new Date(now.getTime() - 1 * 60 * 60 * 1000), // 1 hour ago
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: regularUser.id,
|
||||||
|
action: 'create_booking',
|
||||||
|
entityType: 'booking',
|
||||||
|
entityId: sampleBookings[1].id,
|
||||||
|
details: JSON.stringify({
|
||||||
|
courtId: courts[1] ? courts[1].id : courts[0].id,
|
||||||
|
courtName: courts[1] ? courts[1].name : courts[0].name,
|
||||||
|
date: tomorrow,
|
||||||
|
startTime: '20:00',
|
||||||
|
endTime: '21:00',
|
||||||
|
}),
|
||||||
|
ipAddress: '192.168.1.101',
|
||||||
|
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15',
|
||||||
|
createdAt: new Date(now.getTime() - 1 * 60 * 60 * 1000), // 1 hour ago
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
userId: adminUser.id,
|
||||||
|
action: 'update_settings',
|
||||||
|
entityType: 'settings',
|
||||||
|
entityId: null,
|
||||||
|
details: JSON.stringify({
|
||||||
|
changedSettings: ['booking_window_days', 'max_booking_duration_hours'],
|
||||||
|
previousValues: { booking_window_days: '7', max_booking_duration_hours: '2' },
|
||||||
|
newValues: { booking_window_days: '14', max_booking_duration_hours: '3' },
|
||||||
|
}),
|
||||||
|
ipAddress: '192.168.1.100',
|
||||||
|
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
|
||||||
|
createdAt: new Date(now.getTime() - 30 * 60 * 1000), // 30 minutes ago
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.insert(activityLogs).values(sampleLogs);
|
||||||
|
|
||||||
|
// Add more announcements for testing
|
||||||
|
console.log('Creating additional announcements...');
|
||||||
|
|
||||||
|
const additionalAnnouncements = [
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
title: 'New Court Rules',
|
||||||
|
content: 'Please remember to clean up after your sessions and respect the time limits.',
|
||||||
|
isActive: true,
|
||||||
|
priority: 'medium' as const,
|
||||||
|
expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000), // 1 week from now
|
||||||
|
createdAt: new Date(now.getTime() - 4 * 60 * 60 * 1000), // 4 hours ago
|
||||||
|
updatedAt: new Date(now.getTime() - 4 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: randomUUID(),
|
||||||
|
title: 'Tournament Sign-ups Open',
|
||||||
|
content: 'The annual table tennis tournament sign-ups are now open! Register by the end of this month.',
|
||||||
|
isActive: true,
|
||||||
|
priority: 'high' as const,
|
||||||
|
expiresAt: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), // 30 days from now
|
||||||
|
createdAt: new Date(now.getTime() - 24 * 60 * 60 * 1000), // 1 day ago
|
||||||
|
updatedAt: new Date(now.getTime() - 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
await db.insert(announcements).values(additionalAnnouncements);
|
||||||
|
|
||||||
|
console.log('Sample data seeding completed successfully!');
|
||||||
|
console.log(`Created:
|
||||||
|
- ${sampleBookings.length} sample bookings
|
||||||
|
- ${sampleLogs.length} activity logs
|
||||||
|
- ${additionalAnnouncements.length} additional announcements`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error seeding data:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedData();
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { users, courts, timeSlots, settings } from '@/lib/db/schema';
|
||||||
|
import { hashPassword } from '@/lib/auth';
|
||||||
|
import { generateId } from '@/lib/utils';
|
||||||
|
|
||||||
|
async function setupDatabase() {
|
||||||
|
try {
|
||||||
|
console.log('Setting up database...');
|
||||||
|
|
||||||
|
// Create admin user
|
||||||
|
const adminEmail = process.env.ADMIN_EMAIL || 'admin@example.com';
|
||||||
|
const adminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||||
|
|
||||||
|
const hashedAdminPassword = await hashPassword(adminPassword);
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
id: generateId(),
|
||||||
|
email: adminEmail,
|
||||||
|
name: 'Admin',
|
||||||
|
surname: 'User',
|
||||||
|
password: hashedAdminPassword,
|
||||||
|
role: 'admin',
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
// Create default courts
|
||||||
|
await db
|
||||||
|
.insert(courts)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'Court 1',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
name: 'Court 2',
|
||||||
|
isActive: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
// Create default time slots
|
||||||
|
// Monday (1) and Tuesday (2): 19:00-23:00
|
||||||
|
const mondayTuesdaySlots = [];
|
||||||
|
for (let day of [1, 2]) {
|
||||||
|
for (let hour = 19; hour < 23; hour++) {
|
||||||
|
const hourStr = hour < 10 ? '0' + hour : hour.toString();
|
||||||
|
const nextHourStr = hour + 1 < 10 ? '0' + (hour + 1) : (hour + 1).toString();
|
||||||
|
mondayTuesdaySlots.push({
|
||||||
|
id: generateId(),
|
||||||
|
dayOfWeek: day,
|
||||||
|
startTime: `${hourStr}:00`,
|
||||||
|
endTime: `${nextHourStr}:00`,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sunday (0): 12:00-17:00
|
||||||
|
const sundaySlots = [];
|
||||||
|
for (let hour = 12; hour < 17; hour++) {
|
||||||
|
const hourStr = hour < 10 ? '0' + hour : hour.toString();
|
||||||
|
const nextHourStr = hour + 1 < 10 ? '0' + (hour + 1) : (hour + 1).toString();
|
||||||
|
sundaySlots.push({
|
||||||
|
id: generateId(),
|
||||||
|
dayOfWeek: 0,
|
||||||
|
startTime: `${hourStr}:00`,
|
||||||
|
endTime: `${nextHourStr}:00`,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(timeSlots)
|
||||||
|
.values([...mondayTuesdaySlots, ...sundaySlots])
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
// Create default settings
|
||||||
|
await db
|
||||||
|
.insert(settings)
|
||||||
|
.values([
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
key: 'booking_window_days',
|
||||||
|
value: '7',
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
key: 'max_bookings_per_user',
|
||||||
|
value: '3',
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: generateId(),
|
||||||
|
key: 'booking_cancellation_hours',
|
||||||
|
value: '2',
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
console.log('Database setup completed successfully!');
|
||||||
|
console.log(`Admin user created: ${adminEmail}`);
|
||||||
|
console.log(`Admin password: ${adminPassword}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Database setup error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run setup if this file is executed directly
|
||||||
|
if (require.main === module) {
|
||||||
|
setupDatabase()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch(() => process.exit(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export { setupDatabase };
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
darkMode: ['class'],
|
||||||
|
content: ['./pages/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: {
|
||||||
|
container: {
|
||||||
|
center: true,
|
||||||
|
padding: '2rem',
|
||||||
|
screens: {
|
||||||
|
'2xl': '1400px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
border: 'hsl(var(--border))',
|
||||||
|
input: 'hsl(var(--input))',
|
||||||
|
ring: 'hsl(var(--ring))',
|
||||||
|
background: 'hsl(var(--background))',
|
||||||
|
foreground: 'hsl(var(--foreground))',
|
||||||
|
primary: {
|
||||||
|
DEFAULT: 'hsl(var(--primary))',
|
||||||
|
foreground: 'hsl(var(--primary-foreground))',
|
||||||
|
},
|
||||||
|
secondary: {
|
||||||
|
DEFAULT: 'hsl(var(--secondary))',
|
||||||
|
foreground: 'hsl(var(--secondary-foreground))',
|
||||||
|
},
|
||||||
|
destructive: {
|
||||||
|
DEFAULT: 'hsl(var(--destructive))',
|
||||||
|
foreground: 'hsl(var(--destructive-foreground))',
|
||||||
|
},
|
||||||
|
muted: {
|
||||||
|
DEFAULT: 'hsl(var(--muted))',
|
||||||
|
foreground: 'hsl(var(--muted-foreground))',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'hsl(var(--accent))',
|
||||||
|
foreground: 'hsl(var(--accent-foreground))',
|
||||||
|
},
|
||||||
|
popover: {
|
||||||
|
DEFAULT: 'hsl(var(--popover))',
|
||||||
|
foreground: 'hsl(var(--popover-foreground))',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
DEFAULT: 'hsl(var(--card))',
|
||||||
|
foreground: 'hsl(var(--card-foreground))',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
lg: 'var(--radius)',
|
||||||
|
md: 'calc(var(--radius) - 2px)',
|
||||||
|
sm: 'calc(var(--radius) - 4px)',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
'accordion-down': {
|
||||||
|
from: { height: 0 },
|
||||||
|
to: { height: 'var(--radix-accordion-content-height)' },
|
||||||
|
},
|
||||||
|
'accordion-up': {
|
||||||
|
from: { height: 'var(--radix-accordion-content-height)' },
|
||||||
|
to: { height: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'accordion-down': 'accordion-down 0.2s ease-out',
|
||||||
|
'accordion-up': 'accordion-up 0.2s ease-out',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [require('tailwindcss-animate')],
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "es6"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"],
|
||||||
|
"@/components/*": ["./components/*"],
|
||||||
|
"@/lib/*": ["./lib/*"],
|
||||||
|
"@/app/*": ["./app/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user