From c8062cf96b589d08ae67f9b2ded7189ecf80815c Mon Sep 17 00:00:00 2001 From: mikicvi <88291034+mikicvi@users.noreply.github.com> Date: Sun, 21 Sep 2025 17:11:02 +0100 Subject: [PATCH] initial version of the app --- .env.example | 14 + .gitignore | 52 + Dockerfile | 38 + README.md | 201 + app/admin/page.tsx | 5 + app/api/admin/announcements/[id]/route.ts | 76 + app/api/admin/announcements/route.ts | 63 + app/api/admin/courts/[id]/route.ts | 73 + app/api/admin/courts/route.ts | 59 + app/api/admin/logs/route.ts | 93 + app/api/admin/recent-bookings/route.ts | 52 + app/api/admin/settings/route.ts | 78 + app/api/admin/users/[id]/route.ts | 90 + app/api/admin/users/route.ts | 83 + app/api/announcements/route.ts | 36 + app/api/auth/login/route.ts | 65 + app/api/auth/logout/route.ts | 7 + app/api/auth/register/route.ts | 70 + app/api/bookings/[id]/route.ts | 145 + app/api/bookings/route-clean.ts | 129 + app/api/bookings/route-new.ts | 129 + app/api/bookings/route.ts | 152 + app/api/courts/route.ts | 24 + app/api/dashboard/recent-bookings/route.ts | 55 + app/api/dashboard/stats/route.ts | 57 + app/api/settings/route.ts | 23 + app/api/users/profile/route.ts | 103 + app/dashboard/page.tsx | 40 + app/globals.css | 76 + app/layout.tsx | 25 + app/login/page.tsx | 24 + app/page.tsx | 16 + app/register/page.tsx | 24 + components.json | 22 + .../admin/AdminAnnouncementManagement.tsx | 544 + components/admin/AdminCourtManagement.tsx | 342 + components/admin/AdminLogs.tsx | 175 + components/admin/AdminRecentBookings.tsx | 152 + components/admin/AdminSettingsManagement.tsx | 295 + components/admin/AdminUserManagement.tsx | 484 + components/admin/admin-dashboard.tsx | 141 + .../announcements/announcements-list.tsx | 123 + components/auth/LoginForm.tsx | 133 + components/auth/RegisterForm.tsx | 191 + components/auth/login-form.tsx | 101 + components/auth/register-form.tsx | 158 + components/booking/booking-calendar.tsx | 290 + .../booking/enhanced-booking-calendar.tsx | 525 + .../booking/user-booking-management.tsx | 429 + components/dashboard/BookingCalendar.tsx | 191 + components/dashboard/DashboardHeader.tsx | 90 + components/dashboard/QuickStats-new.tsx | 135 + components/dashboard/QuickStats.tsx | 135 + components/dashboard/RecentBookings.tsx | 126 + components/dashboard/dashboard-header.tsx | 134 + components/notifications/announcements.tsx | 179 + components/theme-provider.tsx | 9 + components/ui/alert-dialog.tsx | 106 + components/ui/badge.tsx | 30 + components/ui/button.tsx | 47 + components/ui/calendar.tsx | 213 + components/ui/card.tsx | 43 + components/ui/dialog.tsx | 122 + components/ui/form.tsx | 178 + components/ui/input.tsx | 22 + components/ui/label.tsx | 26 + components/ui/select.tsx | 159 + components/ui/switch.tsx | 29 + components/ui/table.tsx | 120 + components/ui/tabs.tsx | 55 + components/ui/textarea.tsx | 22 + components/ui/toast.tsx | 113 + components/ui/toaster.tsx | 26 + components/ui/use-toast.ts | 187 + components/user/user-profile.tsx | 284 + docker-compose.yml | 32 + drizzle.config.ts | 10 + hooks/use-toast.ts | 189 + lib/activity-logger.ts | 95 + lib/auth.ts | 48 + lib/db/index.ts | 15 + lib/db/migrations/meta/_journal.json | 1 + lib/db/schema.ts | 124 + lib/email.ts | 107 + lib/session.ts | 101 + lib/utils.ts | 62 + middleware.ts | 40 + next-env.d.ts | 6 + next.config.js | 13 + nginx.conf | 78 + package-lock.json | 12070 ++++++++++++++++ package.json | 73 + postcss.config.js | 6 + scripts/init-db.ts | 176 + scripts/reset-database.ts | 263 + scripts/seed-announcements.ts | 50 + scripts/seed-data.ts | 203 + scripts/setup-db.ts | 133 + tailwind.config.js | 71 + tsconfig.json | 31 + tsconfig.tsbuildinfo | 1 + 101 files changed, 23061 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/admin/page.tsx create mode 100644 app/api/admin/announcements/[id]/route.ts create mode 100644 app/api/admin/announcements/route.ts create mode 100644 app/api/admin/courts/[id]/route.ts create mode 100644 app/api/admin/courts/route.ts create mode 100644 app/api/admin/logs/route.ts create mode 100644 app/api/admin/recent-bookings/route.ts create mode 100644 app/api/admin/settings/route.ts create mode 100644 app/api/admin/users/[id]/route.ts create mode 100644 app/api/admin/users/route.ts create mode 100644 app/api/announcements/route.ts create mode 100644 app/api/auth/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/register/route.ts create mode 100644 app/api/bookings/[id]/route.ts create mode 100644 app/api/bookings/route-clean.ts create mode 100644 app/api/bookings/route-new.ts create mode 100644 app/api/bookings/route.ts create mode 100644 app/api/courts/route.ts create mode 100644 app/api/dashboard/recent-bookings/route.ts create mode 100644 app/api/dashboard/stats/route.ts create mode 100644 app/api/settings/route.ts create mode 100644 app/api/users/profile/route.ts create mode 100644 app/dashboard/page.tsx create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/login/page.tsx create mode 100644 app/page.tsx create mode 100644 app/register/page.tsx create mode 100644 components.json create mode 100644 components/admin/AdminAnnouncementManagement.tsx create mode 100644 components/admin/AdminCourtManagement.tsx create mode 100644 components/admin/AdminLogs.tsx create mode 100644 components/admin/AdminRecentBookings.tsx create mode 100644 components/admin/AdminSettingsManagement.tsx create mode 100644 components/admin/AdminUserManagement.tsx create mode 100644 components/admin/admin-dashboard.tsx create mode 100644 components/announcements/announcements-list.tsx create mode 100644 components/auth/LoginForm.tsx create mode 100644 components/auth/RegisterForm.tsx create mode 100644 components/auth/login-form.tsx create mode 100644 components/auth/register-form.tsx create mode 100644 components/booking/booking-calendar.tsx create mode 100644 components/booking/enhanced-booking-calendar.tsx create mode 100644 components/booking/user-booking-management.tsx create mode 100644 components/dashboard/BookingCalendar.tsx create mode 100644 components/dashboard/DashboardHeader.tsx create mode 100644 components/dashboard/QuickStats-new.tsx create mode 100644 components/dashboard/QuickStats.tsx create mode 100644 components/dashboard/RecentBookings.tsx create mode 100644 components/dashboard/dashboard-header.tsx create mode 100644 components/notifications/announcements.tsx create mode 100644 components/theme-provider.tsx create mode 100644 components/ui/alert-dialog.tsx create mode 100644 components/ui/badge.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/calendar.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/dialog.tsx create mode 100644 components/ui/form.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/select.tsx create mode 100644 components/ui/switch.tsx create mode 100644 components/ui/table.tsx create mode 100644 components/ui/tabs.tsx create mode 100644 components/ui/textarea.tsx create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 components/ui/use-toast.ts create mode 100644 components/user/user-profile.tsx create mode 100644 docker-compose.yml create mode 100644 drizzle.config.ts create mode 100644 hooks/use-toast.ts create mode 100644 lib/activity-logger.ts create mode 100644 lib/auth.ts create mode 100644 lib/db/index.ts create mode 100644 lib/db/migrations/meta/_journal.json create mode 100644 lib/db/schema.ts create mode 100644 lib/email.ts create mode 100644 lib/session.ts create mode 100644 lib/utils.ts create mode 100644 middleware.ts create mode 100644 next-env.d.ts create mode 100644 next.config.js create mode 100644 nginx.conf create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 scripts/init-db.ts create mode 100644 scripts/reset-database.ts create mode 100644 scripts/seed-announcements.ts create mode 100644 scripts/seed-data.ts create mode 100644 scripts/setup-db.ts create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 tsconfig.tsbuildinfo diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e61504f --- /dev/null +++ b/.env.example @@ -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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d837b9 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c05c5bd --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..418210d --- /dev/null +++ b/README.md @@ -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 + 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. diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..37751ae --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,5 @@ +import { AdminDashboard } from '@/components/admin/admin-dashboard'; + +export default function AdminPage() { + return ; +} diff --git a/app/api/admin/announcements/[id]/route.ts b/app/api/admin/announcements/[id]/route.ts new file mode 100644 index 0000000..a3ef906 --- /dev/null +++ b/app/api/admin/announcements/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/announcements/route.ts b/app/api/admin/announcements/route.ts new file mode 100644 index 0000000..798e741 --- /dev/null +++ b/app/api/admin/announcements/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/courts/[id]/route.ts b/app/api/admin/courts/[id]/route.ts new file mode 100644 index 0000000..19b972d --- /dev/null +++ b/app/api/admin/courts/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/courts/route.ts b/app/api/admin/courts/route.ts new file mode 100644 index 0000000..f7ea5ac --- /dev/null +++ b/app/api/admin/courts/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/logs/route.ts b/app/api/admin/logs/route.ts new file mode 100644 index 0000000..fd4fe5a --- /dev/null +++ b/app/api/admin/logs/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/recent-bookings/route.ts b/app/api/admin/recent-bookings/route.ts new file mode 100644 index 0000000..56fb72a --- /dev/null +++ b/app/api/admin/recent-bookings/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/settings/route.ts b/app/api/admin/settings/route.ts new file mode 100644 index 0000000..afd084b --- /dev/null +++ b/app/api/admin/settings/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/users/[id]/route.ts b/app/api/admin/users/[id]/route.ts new file mode 100644 index 0000000..671435b --- /dev/null +++ b/app/api/admin/users/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/admin/users/route.ts b/app/api/admin/users/route.ts new file mode 100644 index 0000000..18d1540 --- /dev/null +++ b/app/api/admin/users/route.ts @@ -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 }); + } +} diff --git a/app/api/announcements/route.ts b/app/api/announcements/route.ts new file mode 100644 index 0000000..e252edd --- /dev/null +++ b/app/api/announcements/route.ts @@ -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 }); + } +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..4c0aa3e --- /dev/null +++ b/app/api/auth/login/route.ts @@ -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 }); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..c5a25a0 --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -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' }); +} diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..815c331 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -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 }); + } +} diff --git a/app/api/bookings/[id]/route.ts b/app/api/bookings/[id]/route.ts new file mode 100644 index 0000000..8f4538c --- /dev/null +++ b/app/api/bookings/[id]/route.ts @@ -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 }); + } +} diff --git a/app/api/bookings/route-clean.ts b/app/api/bookings/route-clean.ts new file mode 100644 index 0000000..a132019 --- /dev/null +++ b/app/api/bookings/route-clean.ts @@ -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 }); + } +} diff --git a/app/api/bookings/route-new.ts b/app/api/bookings/route-new.ts new file mode 100644 index 0000000..a132019 --- /dev/null +++ b/app/api/bookings/route-new.ts @@ -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 }); + } +} diff --git a/app/api/bookings/route.ts b/app/api/bookings/route.ts new file mode 100644 index 0000000..ee58ea1 --- /dev/null +++ b/app/api/bookings/route.ts @@ -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 }); + } +} diff --git a/app/api/courts/route.ts b/app/api/courts/route.ts new file mode 100644 index 0000000..64f8c59 --- /dev/null +++ b/app/api/courts/route.ts @@ -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 }); + } +} diff --git a/app/api/dashboard/recent-bookings/route.ts b/app/api/dashboard/recent-bookings/route.ts new file mode 100644 index 0000000..1cddab0 --- /dev/null +++ b/app/api/dashboard/recent-bookings/route.ts @@ -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 }); + } +} diff --git a/app/api/dashboard/stats/route.ts b/app/api/dashboard/stats/route.ts new file mode 100644 index 0000000..f5536a0 --- /dev/null +++ b/app/api/dashboard/stats/route.ts @@ -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 }); + } +} diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts new file mode 100644 index 0000000..bc0b7db --- /dev/null +++ b/app/api/settings/route.ts @@ -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 }); + } +} diff --git a/app/api/users/profile/route.ts b/app/api/users/profile/route.ts new file mode 100644 index 0000000..3c0c33a --- /dev/null +++ b/app/api/users/profile/route.ts @@ -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 }); + } +} diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..883afea --- /dev/null +++ b/app/dashboard/page.tsx @@ -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 ( +
+ + +
+
+ {/* Main Content */} +
+
+

+ Welcome back, {session.email.split('@')[0]}! 🏓 +

+

Book your table tennis court and enjoy your game

+
+ + +
+ + {/* Sidebar */} +
+ +
+
+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..d22c59d --- /dev/null +++ b/app/globals.css @@ -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; + } +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..be59309 --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + {children} + + + + + ); +} diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..b38c49d --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,24 @@ +import Link from 'next/link'; +import { LoginForm } from '@/components/auth/LoginForm'; + +export default function LoginPage() { + return ( +
+
+
+

🏓 TT Booking

+

Professional table tennis court booking system

+
+ + + +
+ Don't have an account? + + Sign up + +
+
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..d3caac1 --- /dev/null +++ b/app/page.tsx @@ -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'); + } +} diff --git a/app/register/page.tsx b/app/register/page.tsx new file mode 100644 index 0000000..8931d70 --- /dev/null +++ b/app/register/page.tsx @@ -0,0 +1,24 @@ +import Link from 'next/link'; +import { RegisterForm } from '@/components/auth/RegisterForm'; + +export default function RegisterPage() { + return ( +
+
+
+

🏓 TT Booking

+

Join our table tennis community

+
+ + + +
+ Already have an account? + + Sign in + +
+
+
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..36ef86e --- /dev/null +++ b/components.json @@ -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": {} +} diff --git a/components/admin/AdminAnnouncementManagement.tsx b/components/admin/AdminAnnouncementManagement.tsx new file mode 100644 index 0000000..4e14665 --- /dev/null +++ b/components/admin/AdminAnnouncementManagement.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [editingAnnouncement, setEditingAnnouncement] = useState(null); + const [formData, setFormData] = useState({ + 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 ; + case 'medium': + return ; + case 'low': + return ; + default: + return ; + } + }; + + const isExpired = (expiresAt?: string) => { + if (!expiresAt) return false; + return new Date(expiresAt) < new Date(); + }; + + if (loading) { + return ( + + +
+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

Announcement Management

+
+ + + + + + + Create New Announcement + +
+
+ + setFormData({ ...formData, title: e.target.value })} + placeholder='Announcement title' + /> +
+
+ +