Compare commits
15 Commits
3d90563275
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f45a6f46a5 | |||
| 40c56770a2 | |||
| 54240a2cfd | |||
| ab1ac4427a | |||
| 69b456f3f8 | |||
| 7fdd7285a4 | |||
| 1911aa9211 | |||
| 43c0cf1359 | |||
| d4aa460f91 | |||
| 38ee5a8886 | |||
| 2d31c49235 | |||
| c70351ebeb | |||
| 6706ba7f1f | |||
| 838c4906ab | |||
| 787ac1a4ff |
@@ -1,14 +0,0 @@
|
|||||||
# 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"
|
|
||||||
+21
-2
@@ -4,6 +4,7 @@
|
|||||||
.env.development.local
|
.env.development.local
|
||||||
.env.test.local
|
.env.test.local
|
||||||
.env.production.local
|
.env.production.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
# Next.js
|
# Next.js
|
||||||
.next/
|
.next/
|
||||||
@@ -16,6 +17,8 @@ node_modules/
|
|||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
pids
|
pids
|
||||||
@@ -27,10 +30,14 @@ pids
|
|||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
data/
|
||||||
|
backups/
|
||||||
|
|
||||||
# IDE
|
# IDE
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
# OS generated files
|
# OS generated files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -47,6 +54,18 @@ build/
|
|||||||
|
|
||||||
# Coverage directory used by tools like istanbul
|
# Coverage directory used by tools like istanbul
|
||||||
coverage/
|
coverage/
|
||||||
|
|
||||||
# nyc test coverage
|
|
||||||
.nyc_output
|
.nyc_output
|
||||||
|
# Temporary files
|
||||||
|
*.tmp
|
||||||
|
*.temp
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
|
||||||
|
# Backups
|
||||||
|
*.bak
|
||||||
|
*.backup
|
||||||
|
|
||||||
|
# Runtime directories (created during execution)
|
||||||
|
/logs
|
||||||
|
/backups
|
||||||
|
/data
|
||||||
|
|||||||
-38
@@ -1,38 +0,0 @@
|
|||||||
# 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"]
|
|
||||||
+32
-25
@@ -14,8 +14,8 @@ RUN \
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Rebuild the source code only when needed
|
# Rebuild the source code only when needed
|
||||||
FROM base AS builder
|
FROM node:22-alpine AS builder
|
||||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
RUN apk add --no-cache python3 make g++ curl
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -23,44 +23,50 @@ COPY . .
|
|||||||
# Rebuild better-sqlite3 for Alpine Linux
|
# Rebuild better-sqlite3 for Alpine Linux
|
||||||
RUN npm rebuild better-sqlite3
|
RUN npm rebuild better-sqlite3
|
||||||
|
|
||||||
|
# Build TypeScript database scripts to JavaScript
|
||||||
|
RUN node build-scripts.js
|
||||||
|
|
||||||
# Build the application
|
# Build the application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Remove development-only dependencies and dedupe after build
|
||||||
|
RUN npm prune --omit=dev && npm dedupe --prod \
|
||||||
|
&& find node_modules -type f \( -name "README" -o -name "README.*" -o -name "CHANGELOG*" -o -name "*.md" -o -name "*.map" \) -delete \
|
||||||
|
&& find node_modules -type d \( -name "__tests__" -o -name "test" -o -name "tests" -o -name "docs" -o -name "examples" \) -prune -exec rm -rf {} + \
|
||||||
|
&& npm cache clean --force
|
||||||
|
|
||||||
# Production image, copy all the files and run next
|
# Production image, copy all the files and run next
|
||||||
FROM base AS runner
|
FROM node:22-alpine AS runner
|
||||||
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
|
RUN apk add --no-cache curl
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
# Create system user and group
|
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
|
||||||
RUN adduser --system --uid 1001 nextjs
|
|
||||||
|
|
||||||
# Copy necessary files from builder stage
|
# Copy necessary files from builder stage
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
COPY --from=builder --chown=root:root /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
# Copy compiled database scripts instead of TypeScript sources
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
|
||||||
|
# Copy minimal runtime dependencies for database operations
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copy database config and package.json
|
||||||
|
COPY --from=builder /app/lib ./lib
|
||||||
|
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
|
||||||
|
# Copy entrypoint script
|
||||||
|
COPY docker-entrypoint-alpine.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Create public directory if it doesn't exist
|
# Create public directory if it doesn't exist
|
||||||
RUN mkdir -p public
|
RUN mkdir -p public
|
||||||
|
|
||||||
# Create directories for data and backups
|
# Create directories for data and backups
|
||||||
RUN mkdir -p /app/data /app/backups /app/logs && \
|
RUN mkdir -p /app/data /app/backups /app/logs
|
||||||
chown -R nextjs:nodejs /app/data /app/backups /app/logs
|
|
||||||
|
|
||||||
# Create startup script
|
|
||||||
COPY --chown=nextjs:nodejs <<EOF /app/start.sh
|
|
||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
echo "🚀 Starting TT Booking Production..."
|
|
||||||
echo "🌟 Starting server..."
|
|
||||||
exec node server.js
|
|
||||||
EOF
|
|
||||||
|
|
||||||
RUN chmod +x /app/start.sh
|
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
@@ -71,4 +77,5 @@ ENV HOSTNAME="0.0.0.0"
|
|||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
CMD curl -f http://localhost:3000/api/health || exit 1
|
CMD curl -f http://localhost:3000/api/health || exit 1
|
||||||
|
|
||||||
CMD ["/app/start.sh"]
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
CMD ["node", "server.js"]
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License:
|
||||||
|
|
||||||
|
```
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Vilim Mikic
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
```
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
# LCC Table Tennis Booking - Production Setup
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
Your production environment is configured for domain: **lcc-tt-booking.mikicvi.com**
|
|
||||||
|
|
||||||
### 1. Deploy the Application
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Make deployment script executable (if not already done)
|
|
||||||
chmod +x deploy.sh
|
|
||||||
|
|
||||||
# Deploy to production
|
|
||||||
./deploy.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Set Up Cloudflare Tunnel
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run the tunnel setup script
|
|
||||||
./setup-tunnel.sh
|
|
||||||
|
|
||||||
# Follow the instructions provided by the script
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration Files Created
|
|
||||||
|
|
||||||
### Environment Configuration
|
|
||||||
|
|
||||||
- **`.env.production`** - Production environment variables
|
|
||||||
- **`docker-compose.production.yml`** - Production Docker Compose configuration
|
|
||||||
- **`Dockerfile.production`** - Optimized production Docker build
|
|
||||||
|
|
||||||
### Security & Secrets
|
|
||||||
|
|
||||||
- **NEXTAUTH_SECRET**: `qHYNaq516ByAY6H4HdxacMICd05I1DqvrTitIuVtT20=` (pre-generated)
|
|
||||||
- **Domain**: `lcc-tt-booking.mikicvi.com`
|
|
||||||
- **Admin Email**: `admin@lcc-tt-booking.mikicvi.com`
|
|
||||||
|
|
||||||
### Scripts & Automation
|
|
||||||
|
|
||||||
- **`deploy.sh`** - One-command production deployment
|
|
||||||
- **`setup-tunnel.sh`** - Cloudflare Tunnel setup assistant
|
|
||||||
- **`cloudflare-tunnel-config.yml`** - Tunnel configuration template
|
|
||||||
|
|
||||||
### Health & Monitoring
|
|
||||||
|
|
||||||
- **`/api/health`** - Health check endpoint with database connectivity and memory usage
|
|
||||||
- **Automated backups** - Daily SQLite backups (30-day retention)
|
|
||||||
- **Log rotation** - Automatic log management
|
|
||||||
|
|
||||||
## Production Checklist
|
|
||||||
|
|
||||||
### Before Going Live:
|
|
||||||
|
|
||||||
- [ ] Update email credentials in `.env.production`
|
|
||||||
- [ ] Change admin password from default
|
|
||||||
- [ ] Set up Cloudflare Tunnel
|
|
||||||
- [ ] Test health check endpoint
|
|
||||||
- [ ] Verify SSL certificate is working
|
|
||||||
|
|
||||||
### After Deployment:
|
|
||||||
|
|
||||||
- [ ] Test all booking functionality
|
|
||||||
- [ ] Verify admin panel access
|
|
||||||
- [ ] Check automated backups are working
|
|
||||||
- [ ] Set up monitoring alerts (optional)
|
|
||||||
|
|
||||||
## Management Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# View application logs
|
|
||||||
docker-compose -f docker-compose.production.yml logs -f tt-booking
|
|
||||||
|
|
||||||
# Restart application
|
|
||||||
docker-compose -f docker-compose.production.yml restart tt-booking
|
|
||||||
|
|
||||||
# Stop all services
|
|
||||||
docker-compose -f docker-compose.production.yml down
|
|
||||||
|
|
||||||
# Update and redeploy
|
|
||||||
./deploy.sh
|
|
||||||
|
|
||||||
# Access database backup
|
|
||||||
ls -la backups/
|
|
||||||
|
|
||||||
# Check application health
|
|
||||||
curl http://localhost:3000/api/health
|
|
||||||
```
|
|
||||||
|
|
||||||
## Directory Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
/Users/mikicv/Documents/tt-booking/
|
|
||||||
├── .env.production # Production environment variables
|
|
||||||
├── docker-compose.production.yml # Production Docker Compose
|
|
||||||
├── Dockerfile.production # Production Docker build
|
|
||||||
├── deploy.sh # Deployment script
|
|
||||||
├── setup-tunnel.sh # Cloudflare Tunnel setup
|
|
||||||
├── cloudflare-tunnel-config.yml # Tunnel configuration
|
|
||||||
├── data/ # SQLite database storage
|
|
||||||
├── backups/ # Automated backups
|
|
||||||
└── logs/ # Application logs
|
|
||||||
```
|
|
||||||
|
|
||||||
## Default Admin Access
|
|
||||||
|
|
||||||
- **URL**: https://lcc-tt-booking.mikicvi.com/admin
|
|
||||||
- **Email**: admin@lcc-tt-booking.mikicvi.com
|
|
||||||
- **Password**: ChangeMeInProduction123! (⚠️ CHANGE THIS!)
|
|
||||||
|
|
||||||
## Support & Troubleshooting
|
|
||||||
|
|
||||||
### Common Issues:
|
|
||||||
|
|
||||||
1. **Container won't start**: Check `docker-compose -f docker-compose.production.yml logs`
|
|
||||||
2. **Database issues**: Ensure `data/` directory permissions are correct
|
|
||||||
3. **Tunnel not working**: Verify Cloudflare DNS settings and tunnel configuration
|
|
||||||
|
|
||||||
### Health Check:
|
|
||||||
|
|
||||||
The health endpoint (`/api/health`) provides:
|
|
||||||
|
|
||||||
- Application status
|
|
||||||
- Database connectivity
|
|
||||||
- Memory usage
|
|
||||||
- Uptime information
|
|
||||||
|
|
||||||
### Backup Verification:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# List all backups
|
|
||||||
ls -la backups/
|
|
||||||
|
|
||||||
# Check latest backup size
|
|
||||||
du -h backups/sqlite-$(date +%Y%m%d)*.db | tail -1
|
|
||||||
```
|
|
||||||
|
|
||||||
## Production Features Included:
|
|
||||||
|
|
||||||
- ✅ Automated daily backups (30-day retention)
|
|
||||||
- ✅ Health monitoring endpoint
|
|
||||||
- ✅ Log rotation and management
|
|
||||||
- ✅ Multi-stage Docker optimization
|
|
||||||
- ✅ Security hardening
|
|
||||||
- ✅ Rate limiting configured
|
|
||||||
- ✅ SSL-ready with Cloudflare integration
|
|
||||||
|
|
||||||
Your LCC Table Tennis Booking System is ready for production! 🏓
|
|
||||||
@@ -2,133 +2,93 @@
|
|||||||
|
|
||||||
A modern, full-stack table tennis court booking system built with Next.js, shadcn/ui, and SQLite.
|
A modern, full-stack table tennis court booking system built with Next.js, shadcn/ui, and SQLite.
|
||||||
|
|
||||||
## Features
|
## ✨ Features
|
||||||
|
|
||||||
### User Features
|
- **🔐 Secure Authentication**: JWT-based registration and login
|
||||||
|
- **📅 Interactive Calendar**: Real-time court availability and booking
|
||||||
|
- **📧 Email Notifications**: Automatic booking confirmations
|
||||||
|
- **📱 Mobile-First Design**: Responsive UI for all devices
|
||||||
|
- **� Admin Panel**: Court management, user administration, analytics
|
||||||
|
- **🔒 Security**: Rate limiting, input validation, password hashing
|
||||||
|
|
||||||
- **Secure Authentication**: User registration and login with JWT tokens
|
## 🚀 Quick Start
|
||||||
- **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
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 18+
|
- Node.js 18+
|
||||||
- npm or yarn
|
- npm/yarn
|
||||||
- Gmail account for email notifications
|
- Gmail account (for notifications)
|
||||||
|
|
||||||
### Installation
|
### 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
|
```bash
|
||||||
docker-compose up -d
|
# Clone and install
|
||||||
|
git clone <repository-url>
|
||||||
|
cd tt-booking
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Configure environment
|
||||||
|
cp .env.sample .env.local
|
||||||
|
# Edit .env.local with your settings
|
||||||
|
|
||||||
|
# Setup database
|
||||||
|
npm run db:setup
|
||||||
|
|
||||||
|
# Start development
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Production
|
**Access**: http://localhost:3000 (Admin: `/admin`)
|
||||||
|
|
||||||
1. **Update environment variables** in `docker-compose.yml`
|
## 🔧 Configuration
|
||||||
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
|
Create `.env.local` with:
|
||||||
|
|
||||||
|
```env
|
||||||
|
NEXTAUTH_SECRET=your-very-long-random-secret-key
|
||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
ADMIN_EMAIL=admin@your-domain.com
|
||||||
|
ADMIN_PASSWORD=your-secure-password
|
||||||
|
|
||||||
|
# Optional - Email notifications
|
||||||
|
EMAIL_USER=your-email@gmail.com
|
||||||
|
EMAIL_PASSWORD=your-gmail-app-password
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🗄️ Database Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run db:setup # Full database setup
|
||||||
|
npm run db:seed # Essential data only
|
||||||
|
npm run db:reset-confirm # Reset database
|
||||||
|
npm run db:studio # Open database GUI
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐳 Deployment
|
||||||
|
|
||||||
|
### Production (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.sample .env.production
|
||||||
|
docker compose -f docker-compose.production.yml up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Home Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./setup-tunnel.sh # Setup Cloudflare tunnel
|
||||||
|
./deploy.sh # Deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🛠️ Technology Stack
|
||||||
|
|
||||||
|
- **Frontend**: Next.js 14, React, TypeScript, shadcn/ui, Tailwind CSS
|
||||||
|
- **Backend**: Next.js API routes, SQLite, Drizzle ORM
|
||||||
|
- **Auth**: JWT with httpOnly cookies
|
||||||
|
- **Email**: Nodemailer + Gmail
|
||||||
|
- **Deployment**: Docker, Nginx
|
||||||
|
|
||||||
|
## 🗂️ Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
tt-booking/
|
tt-booking/
|
||||||
@@ -139,63 +99,28 @@ tt-booking/
|
|||||||
│ └── layout.tsx # Root layout
|
│ └── layout.tsx # Root layout
|
||||||
├── components/ # React components
|
├── components/ # React components
|
||||||
│ ├── ui/ # shadcn/ui components
|
│ ├── ui/ # shadcn/ui components
|
||||||
│ ├── auth/ # Authentication forms
|
│ ├── auth/ # Authentication
|
||||||
│ ├── booking/ # Booking components
|
│ ├── booking/ # Booking system
|
||||||
│ └── admin/ # Admin components
|
│ └── admin/ # Admin interface
|
||||||
├── lib/ # Utility libraries
|
├── lib/ # Utilities
|
||||||
│ ├── db/ # Database schema and connection
|
│ ├── db/ # Database schema
|
||||||
│ ├── auth.ts # Authentication utilities
|
│ ├── auth.ts # Authentication
|
||||||
│ ├── email.ts # Email functionality
|
│ └── email.ts # Email service
|
||||||
│ └── utils.ts # General utilities
|
├── scripts/ # Database scripts
|
||||||
├── docker-compose.yml # Docker configuration
|
│ ├── setup-database.ts # Database setup
|
||||||
├── Dockerfile # Container definition
|
│ └── reset-db.ts # Database reset
|
||||||
└── nginx.conf # Reverse proxy configuration
|
└── docker-compose.*.yml # Deployment configs
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Endpoints
|
## 🤝 Contributing
|
||||||
|
|
||||||
### 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
|
1. Fork the repository
|
||||||
2. Create a feature branch
|
2. Create feature branch
|
||||||
3. Make your changes
|
3. Make changes and test
|
||||||
4. Add tests if applicable
|
4. Submit pull request
|
||||||
5. Submit a pull request
|
|
||||||
|
|
||||||
## License
|
## 🆘 Support
|
||||||
|
|
||||||
This project is licensed under the MIT License.
|
- 📚 [Database Setup Guide](docs/DATABASE_SETUP.md)
|
||||||
|
- 🚀 [Deployment Guide](docs/DEPLOYMENT_GUIDE.md)
|
||||||
## Support
|
- 🐛 Create an issue for bugs or questions
|
||||||
|
|
||||||
For issues and questions, please create an issue in the repository.
|
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { courtBlocks, courts } from '@/lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { getSession } from '@/lib/session';
|
||||||
|
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
const { courtId, date, startTime, endTime, reason } = await request.json();
|
||||||
|
|
||||||
|
if (!date || !startTime || !endTime || !reason) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required fields: date, startTime, endTime, reason' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if block exists
|
||||||
|
const existingBlock = await db.select().from(courtBlocks).where(eq(courtBlocks.id, id)).limit(1);
|
||||||
|
|
||||||
|
if (existingBlock.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Block not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date is not in the past
|
||||||
|
const blockDate = new Date(date);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (blockDate < today) {
|
||||||
|
return NextResponse.json({ error: 'Cannot set blocks for past dates' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If courtId is provided, verify it exists
|
||||||
|
if (courtId) {
|
||||||
|
const court = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
|
||||||
|
if (court.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Court not found' }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the block
|
||||||
|
const [updatedBlock] = await db
|
||||||
|
.update(courtBlocks)
|
||||||
|
.set({
|
||||||
|
courtId: courtId || null,
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
reason,
|
||||||
|
})
|
||||||
|
.where(eq(courtBlocks.id, id))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await logActivity({
|
||||||
|
userId: session.userId,
|
||||||
|
action: ACTIONS.BLOCK_UPDATE,
|
||||||
|
entityType: ENTITY_TYPES.COURT_BLOCK,
|
||||||
|
entityId: id,
|
||||||
|
details: {
|
||||||
|
courtId: courtId || 'all',
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
block: updatedBlock,
|
||||||
|
message: 'Block updated successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating block:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const session = await getSession();
|
||||||
|
if (!session || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await context.params;
|
||||||
|
|
||||||
|
// Check if block exists
|
||||||
|
const existingBlock = await db.select().from(courtBlocks).where(eq(courtBlocks.id, id)).limit(1);
|
||||||
|
|
||||||
|
if (existingBlock.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Block not found' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = existingBlock[0];
|
||||||
|
|
||||||
|
// Delete the block
|
||||||
|
await db.delete(courtBlocks).where(eq(courtBlocks.id, id));
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await logActivity({
|
||||||
|
userId: session.userId,
|
||||||
|
action: ACTIONS.BLOCK_DELETE,
|
||||||
|
entityType: ENTITY_TYPES.COURT_BLOCK,
|
||||||
|
entityId: id,
|
||||||
|
details: {
|
||||||
|
courtId: block.courtId || 'all',
|
||||||
|
date: block.date,
|
||||||
|
startTime: block.startTime,
|
||||||
|
endTime: block.endTime,
|
||||||
|
reason: block.reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ message: 'Block deleted successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting block:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { courtBlocks, courts, users, announcements } from '@/lib/db/schema';
|
||||||
|
import { eq, gte, asc } 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 || session.role !== 'admin') {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const includeExpired = searchParams.get('includeExpired') === 'true';
|
||||||
|
|
||||||
|
// Get today's date
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Fetch blocks with court and creator info
|
||||||
|
let query = db
|
||||||
|
.select({
|
||||||
|
id: courtBlocks.id,
|
||||||
|
courtId: courtBlocks.courtId,
|
||||||
|
date: courtBlocks.date,
|
||||||
|
startTime: courtBlocks.startTime,
|
||||||
|
endTime: courtBlocks.endTime,
|
||||||
|
reason: courtBlocks.reason,
|
||||||
|
createdBy: courtBlocks.createdBy,
|
||||||
|
createdAt: courtBlocks.createdAt,
|
||||||
|
court: {
|
||||||
|
id: courts.id,
|
||||||
|
name: courts.name,
|
||||||
|
},
|
||||||
|
creator: {
|
||||||
|
id: users.id,
|
||||||
|
name: users.name,
|
||||||
|
surname: users.surname,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(courtBlocks)
|
||||||
|
.leftJoin(courts, eq(courtBlocks.courtId, courts.id))
|
||||||
|
.innerJoin(users, eq(courtBlocks.createdBy, users.id));
|
||||||
|
|
||||||
|
const rawBlocks = includeExpired
|
||||||
|
? await query.orderBy(asc(courtBlocks.date), asc(courtBlocks.startTime))
|
||||||
|
: await query
|
||||||
|
.where(gte(courtBlocks.date, today))
|
||||||
|
.orderBy(asc(courtBlocks.date), asc(courtBlocks.startTime));
|
||||||
|
|
||||||
|
// Transform to flat structure for frontend
|
||||||
|
const blocks = rawBlocks.map((block) => ({
|
||||||
|
id: block.id,
|
||||||
|
courtId: block.courtId,
|
||||||
|
courtName: block.court?.name || null,
|
||||||
|
date: block.date,
|
||||||
|
startTime: block.startTime,
|
||||||
|
endTime: block.endTime,
|
||||||
|
reason: block.reason,
|
||||||
|
createdBy: block.createdBy,
|
||||||
|
creatorName: block.creator ? `${block.creator.name} ${block.creator.surname}` : 'Unknown',
|
||||||
|
createdAt: block.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ blocks });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching blocks:', 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 { courtId, date, startTime, endTime, reason, createAnnouncement } = await request.json();
|
||||||
|
|
||||||
|
if (!date || !startTime || !endTime || !reason) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing required fields: date, startTime, endTime, reason' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate date is not in the past
|
||||||
|
const blockDate = new Date(date);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
if (blockDate < today) {
|
||||||
|
return NextResponse.json({ error: 'Cannot create blocks for past dates' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get court name if courtId is provided
|
||||||
|
let courtName = 'All Courts';
|
||||||
|
if (courtId) {
|
||||||
|
const court = await db.select().from(courts).where(eq(courts.id, courtId)).limit(1);
|
||||||
|
if (court.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'Court not found' }, { status: 400 });
|
||||||
|
}
|
||||||
|
courtName = court[0].name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the block
|
||||||
|
const blockId = crypto.randomUUID();
|
||||||
|
const [newBlock] = await db
|
||||||
|
.insert(courtBlocks)
|
||||||
|
.values({
|
||||||
|
id: blockId,
|
||||||
|
courtId: courtId || null, // null means all courts
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
reason,
|
||||||
|
createdBy: session.userId,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
await logActivity({
|
||||||
|
userId: session.userId,
|
||||||
|
action: ACTIONS.BLOCK_CREATE,
|
||||||
|
entityType: ENTITY_TYPES.COURT_BLOCK,
|
||||||
|
entityId: blockId,
|
||||||
|
details: {
|
||||||
|
courtId: courtId || 'all',
|
||||||
|
date,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Optionally create an announcement that expires when the block ends
|
||||||
|
let announcementCreated = false;
|
||||||
|
if (createAnnouncement) {
|
||||||
|
const formattedDate = new Date(date).toLocaleDateString('en-IE', {
|
||||||
|
weekday: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set expiry to end of block day at the end time
|
||||||
|
const expiryDate = new Date(date);
|
||||||
|
const [endHour] = endTime.split(':').map(Number);
|
||||||
|
expiryDate.setHours(endHour, 0, 0, 0);
|
||||||
|
|
||||||
|
await db.insert(announcements).values({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
title: `${reason} - ${formattedDate}`,
|
||||||
|
content: `${courtName} will be unavailable on ${formattedDate} from ${startTime} to ${endTime} due to: ${reason}`,
|
||||||
|
priority: 'high',
|
||||||
|
expiresAt: expiryDate,
|
||||||
|
isActive: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
});
|
||||||
|
announcementCreated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
block: newBlock,
|
||||||
|
announcementCreated,
|
||||||
|
message: 'Block created successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating block:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { users } from '@/lib/db/schema';
|
import { users, bookings } from '@/lib/db/schema';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, desc, max, sql, and } from 'drizzle-orm';
|
||||||
import { getSession } from '@/lib/session';
|
import { getSession } from '@/lib/session';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get all users with their last booking date using LEFT JOIN and GROUP BY
|
||||||
const allUsers = await db
|
const allUsers = await db
|
||||||
.select({
|
.select({
|
||||||
id: users.id,
|
id: users.id,
|
||||||
@@ -20,8 +21,11 @@ export async function GET(request: NextRequest) {
|
|||||||
email: users.email,
|
email: users.email,
|
||||||
role: users.role,
|
role: users.role,
|
||||||
createdAt: users.createdAt,
|
createdAt: users.createdAt,
|
||||||
|
lastBookingDate: max(bookings.date),
|
||||||
})
|
})
|
||||||
.from(users);
|
.from(users)
|
||||||
|
.leftJoin(bookings, and(eq(bookings.userId, users.id), eq(bookings.status, 'active')))
|
||||||
|
.groupBy(users.id, users.name, users.surname, users.email, users.role, users.createdAt);
|
||||||
|
|
||||||
return NextResponse.json({ users: allUsers });
|
return NextResponse.json({ users: allUsers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { courtBlocks, courts } from '@/lib/db/schema';
|
||||||
|
import { eq, gte, and, lte, asc } 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 { searchParams } = new URL(request.url);
|
||||||
|
const startDate = searchParams.get('startDate');
|
||||||
|
const endDate = searchParams.get('endDate');
|
||||||
|
|
||||||
|
// Default to today if no start date provided
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const queryStartDate = startDate || today;
|
||||||
|
|
||||||
|
// Default to 60 days ahead if no end date provided (8 weeks + buffer)
|
||||||
|
const defaultEndDate = new Date();
|
||||||
|
defaultEndDate.setDate(defaultEndDate.getDate() + 60);
|
||||||
|
const queryEndDate = endDate || defaultEndDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
// Fetch blocks with court info for the date range
|
||||||
|
const blocks = await db
|
||||||
|
.select({
|
||||||
|
id: courtBlocks.id,
|
||||||
|
courtId: courtBlocks.courtId,
|
||||||
|
date: courtBlocks.date,
|
||||||
|
startTime: courtBlocks.startTime,
|
||||||
|
endTime: courtBlocks.endTime,
|
||||||
|
reason: courtBlocks.reason,
|
||||||
|
court: {
|
||||||
|
id: courts.id,
|
||||||
|
name: courts.name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.from(courtBlocks)
|
||||||
|
.leftJoin(courts, eq(courtBlocks.courtId, courts.id))
|
||||||
|
.where(and(gte(courtBlocks.date, queryStartDate), lte(courtBlocks.date, queryEndDate)))
|
||||||
|
.orderBy(asc(courtBlocks.date), asc(courtBlocks.startTime));
|
||||||
|
|
||||||
|
return NextResponse.json({ blocks });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching blocks:', error);
|
||||||
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
-45
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { bookings, courts, timeSlots, settings, metrics } from '@/lib/db/schema';
|
import { bookings, courts, timeSlots, settings, metrics, courtBlocks } from '@/lib/db/schema';
|
||||||
import { eq, and, gte, asc } from 'drizzle-orm';
|
import { eq, and, gte, asc, or, isNull } from 'drizzle-orm';
|
||||||
import { getSession } from '@/lib/session';
|
import { getSession } from '@/lib/session';
|
||||||
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
|
import { logActivity, ACTIONS, ENTITY_TYPES } from '@/lib/activity-logger';
|
||||||
|
|
||||||
@@ -65,6 +65,9 @@ export async function POST(request: NextRequest) {
|
|||||||
const [hours, minutes] = timeSlot.split(':').map(Number);
|
const [hours, minutes] = timeSlot.split(':').map(Number);
|
||||||
const endTime = `${String(hours + 1).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
const endTime = `${String(hours + 1).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
// Check if user is admin (for bypassing certain restrictions)
|
||||||
|
const isAdmin = session.role === 'admin';
|
||||||
|
|
||||||
// Validate booking date is not in the past
|
// Validate booking date is not in the past
|
||||||
const bookingDate = new Date(date);
|
const bookingDate = new Date(date);
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
@@ -90,6 +93,45 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CHECK FOR BLOCKS - applies to everyone including admins
|
||||||
|
// A block prevents any booking on that court/time (admins should remove the block first)
|
||||||
|
const requestedHour = parseInt(startTime.split(':')[0]);
|
||||||
|
const activeBlocks = await db
|
||||||
|
.select()
|
||||||
|
.from(courtBlocks)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(courtBlocks.date, date),
|
||||||
|
or(
|
||||||
|
eq(courtBlocks.courtId, courtId), // Block for this specific court
|
||||||
|
isNull(courtBlocks.courtId) // Block for all courts
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if any block covers this time slot
|
||||||
|
const isBlockedSlot = activeBlocks.some((block) => {
|
||||||
|
const blockStartHour = parseInt(block.startTime.split(':')[0]);
|
||||||
|
const blockEndHour = parseInt(block.endTime.split(':')[0]);
|
||||||
|
return requestedHour >= blockStartHour && requestedHour < blockEndHour;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isBlockedSlot) {
|
||||||
|
const blockingBlock = activeBlocks.find((block) => {
|
||||||
|
const blockStartHour = parseInt(block.startTime.split(':')[0]);
|
||||||
|
const blockEndHour = parseInt(block.endTime.split(':')[0]);
|
||||||
|
return requestedHour >= blockStartHour && requestedHour < blockEndHour;
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: `This slot is blocked: ${
|
||||||
|
blockingBlock?.reason || 'Court unavailable'
|
||||||
|
}. Please choose a different time.`,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// CRITICAL: Validate that booking is allowed for this day and time
|
// CRITICAL: Validate that booking is allowed for this day and time
|
||||||
const dayOfWeek = bookingDate.getDay();
|
const dayOfWeek = bookingDate.getDay();
|
||||||
const availableTimeSlots = await db
|
const availableTimeSlots = await db
|
||||||
@@ -97,8 +139,8 @@ export async function POST(request: NextRequest) {
|
|||||||
.from(timeSlots)
|
.from(timeSlots)
|
||||||
.where(and(eq(timeSlots.dayOfWeek, dayOfWeek), eq(timeSlots.isActive, true)));
|
.where(and(eq(timeSlots.dayOfWeek, dayOfWeek), eq(timeSlots.isActive, true)));
|
||||||
|
|
||||||
// Check if any time slots are configured for this day
|
// Check if any time slots are configured for this day (admins can bypass if needed)
|
||||||
if (availableTimeSlots.length === 0) {
|
if (availableTimeSlots.length === 0 && !isAdmin) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: `No bookings are allowed on ${
|
error: `No bookings are allowed on ${
|
||||||
@@ -110,54 +152,59 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the requested time slot is within any of the allowed time ranges
|
// Check if the requested time slot is within any of the allowed time ranges
|
||||||
const requestedHour = parseInt(startTime.split(':')[0]);
|
// Admins can bypass time slot restrictions
|
||||||
const isTimeSlotValid = availableTimeSlots.some((slot) => {
|
if (!isAdmin) {
|
||||||
const slotStartHour = parseInt(slot.startTime.split(':')[0]);
|
const isTimeSlotValid = availableTimeSlots.some((slot) => {
|
||||||
const slotEndHour = parseInt(slot.endTime.split(':')[0]);
|
const slotStartHour = parseInt(slot.startTime.split(':')[0]);
|
||||||
return requestedHour >= slotStartHour && requestedHour < slotEndHour;
|
const slotEndHour = parseInt(slot.endTime.split(':')[0]);
|
||||||
});
|
return requestedHour >= slotStartHour && requestedHour < slotEndHour;
|
||||||
|
});
|
||||||
|
|
||||||
if (!isTimeSlotValid) {
|
if (!isTimeSlotValid) {
|
||||||
const allowedRanges = availableTimeSlots.map((slot) => `${slot.startTime}-${slot.endTime}`).join(', ');
|
const allowedRanges = availableTimeSlots.map((slot) => `${slot.startTime}-${slot.endTime}`).join(', ');
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: `Time slot ${startTime} is not available on ${
|
error: `Time slot ${startTime} is not available on ${
|
||||||
['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][dayOfWeek]
|
['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][dayOfWeek]
|
||||||
}s. Available times: ${allowedRanges}`,
|
}s. Available times: ${allowedRanges}`,
|
||||||
},
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check booking restrictions per user per hour per day
|
// Check booking restrictions per user per hour per day
|
||||||
const maxBookingsSetting = await db
|
// Admins bypass this restriction
|
||||||
.select()
|
if (!isAdmin) {
|
||||||
.from(settings)
|
const maxBookingsSetting = await db
|
||||||
.where(eq(settings.key, 'max_bookings_per_user_per_hour_per_day'))
|
.select()
|
||||||
.limit(1);
|
.from(settings)
|
||||||
|
.where(eq(settings.key, 'max_bookings_per_user_per_hour_per_day'))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
const maxBookingsPerHour = maxBookingsSetting.length > 0 ? parseInt(maxBookingsSetting[0].value) : 1; // Default to 1 if setting not found
|
const maxBookingsPerHour = maxBookingsSetting.length > 0 ? parseInt(maxBookingsSetting[0].value) : 1;
|
||||||
|
|
||||||
// Count user's existing bookings for this hour on this day
|
// Count user's existing bookings for this hour on this day
|
||||||
const userBookingsThisHour = await db
|
const userBookingsThisHour = await db
|
||||||
.select()
|
.select()
|
||||||
.from(bookings)
|
.from(bookings)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(bookings.userId, session.userId),
|
eq(bookings.userId, session.userId),
|
||||||
eq(bookings.date, date),
|
eq(bookings.date, date),
|
||||||
eq(bookings.startTime, startTime),
|
eq(bookings.startTime, startTime),
|
||||||
eq(bookings.status, 'active')
|
eq(bookings.status, 'active')
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (userBookingsThisHour.length >= maxBookingsPerHour) {
|
if (userBookingsThisHour.length >= maxBookingsPerHour) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: `You have reached the maximum limit of ${maxBookingsPerHour} booking(s) per hour. You already have ${userBookingsThisHour.length} booking(s) at ${startTime} on this date.`,
|
error: `You have reached the maximum limit of ${maxBookingsPerHour} booking(s) per hour. You already have ${userBookingsThisHour.length} booking(s) at ${startTime} on this date.`,
|
||||||
},
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if slot is already booked
|
// Check if slot is already booked
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execSync } = require('child_process');
|
||||||
|
|
||||||
|
console.log('📦 Building TypeScript database scripts...');
|
||||||
|
|
||||||
|
// Create dist directory
|
||||||
|
const distDir = path.join(__dirname, 'dist');
|
||||||
|
if (!fs.existsSync(distDir)) {
|
||||||
|
fs.mkdirSync(distDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bundle scripts with esbuild (available via Next.js)
|
||||||
|
const scripts = [
|
||||||
|
'scripts/check-database.ts',
|
||||||
|
'scripts/setup-database.ts'
|
||||||
|
];
|
||||||
|
|
||||||
|
scripts.forEach(script => {
|
||||||
|
const scriptName = path.basename(script, '.ts');
|
||||||
|
const outFile = path.join(distDir, `${scriptName}.js`);
|
||||||
|
|
||||||
|
console.log(` Building ${script} -> ${outFile}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(`npx esbuild ${script} --bundle --platform=node --target=node22 --outfile=${outFile} --external:better-sqlite3`, {
|
||||||
|
stdio: 'pipe'
|
||||||
|
});
|
||||||
|
console.log(` ✅ ${scriptName}.js built successfully`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` ❌ Failed to build ${scriptName}:`, error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('🎉 All database scripts built successfully!');
|
||||||
@@ -0,0 +1,753 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Calendar, Trash2, Plus, Ban, CalendarX, Edit, Bell } from 'lucide-react';
|
||||||
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
|
interface Court {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CourtBlock {
|
||||||
|
id: string;
|
||||||
|
courtId: string | null;
|
||||||
|
courtName: string | null;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
reason: string;
|
||||||
|
createdBy: string;
|
||||||
|
creatorName: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminBlocksManagement() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [blocks, setBlocks] = useState<CourtBlock[]>([]);
|
||||||
|
const [courts, setCourts] = useState<Court[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Form state for creating
|
||||||
|
const [selectedCourt, setSelectedCourt] = useState<string>('all');
|
||||||
|
const [blockDate, setBlockDate] = useState<string>('');
|
||||||
|
const [startTime, setStartTime] = useState<string>('18:00');
|
||||||
|
const [endTime, setEndTime] = useState<string>('23:00');
|
||||||
|
const [reason, setReason] = useState<string>('');
|
||||||
|
const [createAnnouncement, setCreateAnnouncement] = useState<boolean>(true);
|
||||||
|
|
||||||
|
// Edit modal state
|
||||||
|
const [editingBlock, setEditingBlock] = useState<CourtBlock | null>(null);
|
||||||
|
const [editCourt, setEditCourt] = useState<string>('all');
|
||||||
|
const [editDate, setEditDate] = useState<string>('');
|
||||||
|
const [editStartTime, setEditStartTime] = useState<string>('');
|
||||||
|
const [editEndTime, setEditEndTime] = useState<string>('');
|
||||||
|
const [editReason, setEditReason] = useState<string>('');
|
||||||
|
|
||||||
|
// Generate time options (e.g., 06:00 to 23:00)
|
||||||
|
const timeOptions = [];
|
||||||
|
for (let h = 6; h <= 23; h++) {
|
||||||
|
timeOptions.push(`${String(h).padStart(2, '0')}:00`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchBlocks = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/blocks');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setBlocks(data.blocks || []);
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch blocks',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching blocks:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to fetch blocks',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
const fetchCourts = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/courts');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setCourts(data.courts || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching courts:', error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBlocks();
|
||||||
|
fetchCourts();
|
||||||
|
}, [fetchBlocks, fetchCourts]);
|
||||||
|
|
||||||
|
const handleCreateBlock = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!blockDate) {
|
||||||
|
toast({
|
||||||
|
title: 'Validation Error',
|
||||||
|
description: 'Please select a date',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!reason.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Validation Error',
|
||||||
|
description: 'Please provide a reason for the block',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startTime >= endTime) {
|
||||||
|
toast({
|
||||||
|
title: 'Validation Error',
|
||||||
|
description: 'End time must be after start time',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/blocks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
courtId: selectedCourt === 'all' ? null : selectedCourt,
|
||||||
|
date: blockDate,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
reason: reason.trim(),
|
||||||
|
createAnnouncement,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: data.announcementCreated
|
||||||
|
? 'Block created and announcement posted'
|
||||||
|
: 'Block created successfully',
|
||||||
|
});
|
||||||
|
// Reset form
|
||||||
|
setBlockDate('');
|
||||||
|
setReason('');
|
||||||
|
setSelectedCourt('all');
|
||||||
|
setStartTime('18:00');
|
||||||
|
setEndTime('22:00');
|
||||||
|
setCreateAnnouncement(true);
|
||||||
|
// Refresh blocks list
|
||||||
|
fetchBlocks();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Failed to create block',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating block:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to create block',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditClick = (block: CourtBlock) => {
|
||||||
|
setEditingBlock(block);
|
||||||
|
setEditCourt(block.courtId || 'all');
|
||||||
|
setEditDate(block.date);
|
||||||
|
setEditStartTime(block.startTime);
|
||||||
|
setEditEndTime(block.endTime);
|
||||||
|
setEditReason(block.reason);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSave = async () => {
|
||||||
|
if (!editingBlock) return;
|
||||||
|
|
||||||
|
if (!editDate || !editReason.trim()) {
|
||||||
|
toast({
|
||||||
|
title: 'Validation Error',
|
||||||
|
description: 'Please fill in all required fields',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editStartTime >= editEndTime) {
|
||||||
|
toast({
|
||||||
|
title: 'Validation Error',
|
||||||
|
description: 'End time must be after start time',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/blocks/${editingBlock.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
courtId: editCourt === 'all' ? null : editCourt,
|
||||||
|
date: editDate,
|
||||||
|
startTime: editStartTime,
|
||||||
|
endTime: editEndTime,
|
||||||
|
reason: editReason.trim(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Block updated successfully',
|
||||||
|
});
|
||||||
|
setEditingBlock(null);
|
||||||
|
fetchBlocks();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Failed to update block',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating block:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to update block',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteBlock = async (blockId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/admin/blocks/${blockId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
toast({
|
||||||
|
title: 'Success',
|
||||||
|
description: 'Block removed successfully',
|
||||||
|
});
|
||||||
|
fetchBlocks();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: data.error || 'Failed to delete block',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting block:', error);
|
||||||
|
toast({
|
||||||
|
title: 'Error',
|
||||||
|
description: 'Failed to delete block',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get min date (today) and max date (e.g., 12 weeks from now)
|
||||||
|
const today = new Date();
|
||||||
|
const minDate = today.toISOString().split('T')[0];
|
||||||
|
const maxDate = new Date(today.getTime() + 84 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; // 12 weeks
|
||||||
|
|
||||||
|
// Format date for display
|
||||||
|
const formatDate = (dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('en-IE', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if a block is in the past
|
||||||
|
const isBlockPast = (dateStr: string) => {
|
||||||
|
const blockDate = new Date(dateStr);
|
||||||
|
blockDate.setHours(23, 59, 59, 999);
|
||||||
|
return blockDate < new Date();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sort blocks by date
|
||||||
|
const sortedBlocks = [...blocks].sort((a, b) => {
|
||||||
|
const dateA = new Date(a.date);
|
||||||
|
const dateB = new Date(b.date);
|
||||||
|
return dateA.getTime() - dateB.getTime();
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
{/* Create Block Form */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2'>
|
||||||
|
<CalendarX className='h-5 w-5' />
|
||||||
|
Block Courts/Hall
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Block court availability for tournaments, AGM, maintenance, or other events. Blocked slots will
|
||||||
|
be visible to members but cannot be booked.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleCreateBlock} className='space-y-4'>
|
||||||
|
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
|
||||||
|
{/* Court Selection */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='court'>Court</Label>
|
||||||
|
<Select value={selectedCourt} onValueChange={setSelectedCourt}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select court' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='all'>All Courts (Entire Hall)</SelectItem>
|
||||||
|
{courts.map((court) => (
|
||||||
|
<SelectItem key={court.id} value={court.id}>
|
||||||
|
{court.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date Selection */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='date'>Date</Label>
|
||||||
|
<Input
|
||||||
|
id='date'
|
||||||
|
type='date'
|
||||||
|
value={blockDate}
|
||||||
|
onChange={(e) => setBlockDate(e.target.value)}
|
||||||
|
min={minDate}
|
||||||
|
max={maxDate}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reason */}
|
||||||
|
<div className='space-y-2 md:col-span-2 lg:col-span-1'>
|
||||||
|
<Label htmlFor='reason'>Reason</Label>
|
||||||
|
<Input
|
||||||
|
id='reason'
|
||||||
|
type='text'
|
||||||
|
placeholder='e.g., Tournament, AGM, Maintenance'
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
maxLength={200}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='grid grid-cols-2 md:grid-cols-4 gap-4'>
|
||||||
|
{/* Start Time */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='startTime'>Start Time</Label>
|
||||||
|
<Select value={startTime} onValueChange={setStartTime}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{timeOptions.map((time) => (
|
||||||
|
<SelectItem key={time} value={time}>
|
||||||
|
{time}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* End Time */}
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<Label htmlFor='endTime'>End Time</Label>
|
||||||
|
<Select value={endTime} onValueChange={setEndTime}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{timeOptions.map((time) => (
|
||||||
|
<SelectItem key={time} value={time}>
|
||||||
|
{time}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create Announcement Toggle */}
|
||||||
|
<div className='col-span-2 flex items-center justify-between p-3 bg-muted/50 rounded-lg'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Bell className='h-4 w-4 text-muted-foreground' />
|
||||||
|
<Label htmlFor='createAnnouncement' className='text-sm cursor-pointer'>
|
||||||
|
Create announcement
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id='createAnnouncement'
|
||||||
|
checked={createAnnouncement}
|
||||||
|
onCheckedChange={setCreateAnnouncement}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{createAnnouncement && (
|
||||||
|
<p className='text-xs text-muted-foreground'>
|
||||||
|
An announcement will be created and automatically expire when the block ends.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<Button type='submit' disabled={submitting} className='w-full md:w-auto'>
|
||||||
|
<Plus className='h-4 w-4 mr-2' />
|
||||||
|
{submitting ? 'Creating...' : 'Create Block'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Existing Blocks List */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className='flex items-center gap-2'>
|
||||||
|
<Ban className='h-5 w-5' />
|
||||||
|
Scheduled Blocks
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Upcoming and current court blocks. Past blocks are shown for reference.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{loading ? (
|
||||||
|
<div className='text-center py-8 text-muted-foreground'>Loading blocks...</div>
|
||||||
|
) : sortedBlocks.length === 0 ? (
|
||||||
|
<div className='text-center py-8 text-muted-foreground'>
|
||||||
|
No blocks scheduled. Create a block above to reserve courts for events.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Desktop Table */}
|
||||||
|
<div className='hidden md:block overflow-x-auto'>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Date</TableHead>
|
||||||
|
<TableHead>Court</TableHead>
|
||||||
|
<TableHead>Time</TableHead>
|
||||||
|
<TableHead>Reason</TableHead>
|
||||||
|
<TableHead>Created By</TableHead>
|
||||||
|
<TableHead className='text-right'>Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sortedBlocks.map((block) => (
|
||||||
|
<TableRow
|
||||||
|
key={block.id}
|
||||||
|
className={isBlockPast(block.date) ? 'opacity-50' : ''}
|
||||||
|
>
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Calendar className='h-4 w-4 text-muted-foreground' />
|
||||||
|
{formatDate(block.date)}
|
||||||
|
{isBlockPast(block.date) && (
|
||||||
|
<Badge variant='outline' className='text-xs'>
|
||||||
|
Past
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{block.courtName ? (
|
||||||
|
<Badge variant='secondary'>{block.courtName}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant='destructive'>All Courts</Badge>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{block.startTime} - {block.endTime}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className='max-w-[200px] truncate' title={block.reason}>
|
||||||
|
{block.reason}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{block.creatorName}</TableCell>
|
||||||
|
<TableCell className='text-right'>
|
||||||
|
<div className='flex items-center justify-end gap-1'>
|
||||||
|
{!isBlockPast(block.date) && (
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => handleEditClick(block)}
|
||||||
|
>
|
||||||
|
<Edit className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
className='text-destructive hover:text-destructive'
|
||||||
|
>
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove Block?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will remove the block for{' '}
|
||||||
|
{formatDate(block.date)} ({block.reason}).
|
||||||
|
Members will be able to book this time slot
|
||||||
|
again.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleDeleteBlock(block.id)}
|
||||||
|
>
|
||||||
|
Remove Block
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Cards */}
|
||||||
|
<div className='md:hidden space-y-4'>
|
||||||
|
{sortedBlocks.map((block) => (
|
||||||
|
<Card key={block.id} className={isBlockPast(block.date) ? 'opacity-50' : ''}>
|
||||||
|
<CardContent className='pt-4'>
|
||||||
|
<div className='flex justify-between items-start mb-2'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Calendar className='h-4 w-4 text-muted-foreground' />
|
||||||
|
<span className='font-medium'>{formatDate(block.date)}</span>
|
||||||
|
{isBlockPast(block.date) && (
|
||||||
|
<Badge variant='outline' className='text-xs'>
|
||||||
|
Past
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center gap-1'>
|
||||||
|
{!isBlockPast(block.date) && (
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => handleEditClick(block)}
|
||||||
|
>
|
||||||
|
<Edit className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
className='text-destructive hover:text-destructive'
|
||||||
|
>
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Remove Block?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
This will remove the block for{' '}
|
||||||
|
{formatDate(block.date)} ({block.reason}).
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleDeleteBlock(block.id)}
|
||||||
|
>
|
||||||
|
Remove Block
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='space-y-1 text-sm'>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<span className='text-muted-foreground'>Court:</span>
|
||||||
|
{block.courtName ? (
|
||||||
|
<Badge variant='secondary'>{block.courtName}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant='destructive'>All Courts</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className='text-muted-foreground'>Time: </span>
|
||||||
|
{block.startTime} - {block.endTime}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className='text-muted-foreground'>Reason: </span>
|
||||||
|
{block.reason}
|
||||||
|
</div>
|
||||||
|
<div className='text-xs text-muted-foreground'>
|
||||||
|
Created by {block.creatorName}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Edit Block Dialog */}
|
||||||
|
<Dialog open={!!editingBlock} onOpenChange={(open) => !open && setEditingBlock(null)}>
|
||||||
|
<DialogContent className='sm:max-w-[425px]'>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Block</DialogTitle>
|
||||||
|
<DialogDescription>Modify the court closure details.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className='grid gap-4 py-4'>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='edit-court'>Court</Label>
|
||||||
|
<Select value={editCourt || 'all'} onValueChange={setEditCourt}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Select a court' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value='all'>All Courts</SelectItem>
|
||||||
|
{courts.map((court) => (
|
||||||
|
<SelectItem key={court.id} value={court.id.toString()}>
|
||||||
|
{court.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='edit-date'>Date</Label>
|
||||||
|
<Input
|
||||||
|
id='edit-date'
|
||||||
|
type='date'
|
||||||
|
value={editDate}
|
||||||
|
onChange={(e) => setEditDate(e.target.value)}
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='edit-start-time'>Start Time</Label>
|
||||||
|
<Select value={editStartTime} onValueChange={setEditStartTime}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='Start time' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{timeOptions.map((time) => (
|
||||||
|
<SelectItem key={time} value={time}>
|
||||||
|
{time}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='edit-end-time'>End Time</Label>
|
||||||
|
<Select value={editEndTime} onValueChange={setEditEndTime}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder='End time' />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{timeOptions.map((time) => (
|
||||||
|
<SelectItem key={time} value={time}>
|
||||||
|
{time}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className='grid gap-2'>
|
||||||
|
<Label htmlFor='edit-reason'>Reason</Label>
|
||||||
|
<Input
|
||||||
|
id='edit-reason'
|
||||||
|
type='text'
|
||||||
|
value={editReason}
|
||||||
|
onChange={(e) => setEditReason(e.target.value)}
|
||||||
|
placeholder='e.g., Tournament, Maintenance'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant='outline' onClick={() => setEditingBlock(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleEditSave} disabled={submitting}>
|
||||||
|
{submitting ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,9 +19,10 @@ import {
|
|||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { toast } from '@/hooks/use-toast';
|
import { toast } from '@/hooks/use-toast';
|
||||||
import { Plus, Edit, Trash2, MapPin, Settings, RefreshCw } from 'lucide-react';
|
import { Plus, Edit, Trash2, MapPin, Settings, RefreshCw, Ban } from 'lucide-react';
|
||||||
|
import { AdminBlocksManagement } from './AdminBlocksManagement';
|
||||||
|
|
||||||
interface Court {
|
interface Court {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -219,7 +220,7 @@ export function AdminCourtManagement() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Court Management</CardTitle>
|
<CardTitle>Courts & Closures</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
@@ -236,151 +237,176 @@ export function AdminCourtManagement() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className='space-y-6'>
|
||||||
<CardHeader className='flex flex-row items-center justify-between'>
|
<Tabs defaultValue='courts' className='w-full'>
|
||||||
<CardTitle className='flex items-center gap-2'>
|
<TabsList className='grid w-full grid-cols-2'>
|
||||||
<Settings className='h-5 w-5' />
|
<TabsTrigger value='courts' className='flex items-center gap-2'>
|
||||||
Court Management
|
<MapPin className='h-4 w-4' />
|
||||||
</CardTitle>
|
Courts
|
||||||
<div className='flex gap-2'>
|
</TabsTrigger>
|
||||||
<Button size='sm' variant='outline' onClick={fetchCourts}>
|
<TabsTrigger value='closures' className='flex items-center gap-2'>
|
||||||
<RefreshCw className='h-4 w-4 mr-2' />
|
<Ban className='h-4 w-4' />
|
||||||
Refresh
|
Closures
|
||||||
</Button>
|
</TabsTrigger>
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
</TabsList>
|
||||||
<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'>
|
<TabsContent value='courts'>
|
||||||
<Switch
|
<Card>
|
||||||
id='isActive'
|
<CardHeader className='flex flex-row items-center justify-between'>
|
||||||
checked={formData.isActive}
|
<CardTitle className='flex items-center gap-2'>
|
||||||
onCheckedChange={(checked: boolean) =>
|
<Settings className='h-5 w-5' />
|
||||||
setFormData({ ...formData, isActive: checked })
|
Court Management
|
||||||
}
|
</CardTitle>
|
||||||
/>
|
<div className='flex gap-2'>
|
||||||
<Label htmlFor='isActive'>Active (available for booking)</Label>
|
<Button size='sm' variant='outline' onClick={fetchCourts}>
|
||||||
</div>
|
<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 justify-end space-x-2'>
|
<div className='flex items-center space-x-2'>
|
||||||
<Button type='button' variant='outline' onClick={resetForm}>
|
<Switch
|
||||||
Cancel
|
id='isActive'
|
||||||
</Button>
|
checked={formData.isActive}
|
||||||
<Button type='submit' disabled={creating || Boolean(editing)}>
|
onCheckedChange={(checked: boolean) =>
|
||||||
{creating || editing ? (
|
setFormData({ ...formData, isActive: checked })
|
||||||
<>
|
}
|
||||||
<RefreshCw className='h-4 w-4 mr-2 animate-spin' />
|
/>
|
||||||
{editingCourt ? 'Updating...' : 'Creating...'}
|
<Label htmlFor='isActive'>Active (available for booking)</Label>
|
||||||
</>
|
</div>
|
||||||
) : 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('en-IE')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='flex items-center gap-3'>
|
<div className='flex justify-end space-x-2'>
|
||||||
<Badge variant={court.isActive ? 'default' : 'secondary'}>
|
<Button type='button' variant='outline' onClick={resetForm}>
|
||||||
{court.isActive ? 'Active' : 'Inactive'}
|
Cancel
|
||||||
</Badge>
|
</Button>
|
||||||
|
<Button type='submit' disabled={creating || Boolean(editing)}>
|
||||||
<div className='flex gap-1'>
|
{creating || editing ? (
|
||||||
<Button
|
<>
|
||||||
size='sm'
|
<RefreshCw className='h-4 w-4 mr-2 animate-spin' />
|
||||||
variant='outline'
|
{editingCourt ? 'Updating...' : 'Creating...'}
|
||||||
onClick={() => handleEdit(court)}
|
</>
|
||||||
disabled={editing === court.id}
|
) : editingCourt ? (
|
||||||
>
|
'Update Court'
|
||||||
<Edit className='h-4 w-4' />
|
) : (
|
||||||
</Button>
|
'Create Court'
|
||||||
<Button
|
)}
|
||||||
size='sm'
|
</Button>
|
||||||
variant='outline'
|
</div>
|
||||||
onClick={() => openDeleteDialog(court)}
|
</form>
|
||||||
disabled={deleting === court.id}
|
</DialogContent>
|
||||||
className='text-red-600 hover:text-red-700'
|
</Dialog>
|
||||||
>
|
|
||||||
{deleting === court.id ? (
|
|
||||||
<RefreshCw className='h-4 w-4 animate-spin' />
|
|
||||||
) : (
|
|
||||||
<Trash2 className='h-4 w-4' />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
</CardHeader>
|
||||||
</div>
|
<CardContent>
|
||||||
)}
|
{courts.length === 0 ? (
|
||||||
</CardContent>
|
<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('en-IE')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
<div className='flex items-center gap-3'>
|
||||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
<Badge variant={court.isActive ? 'default' : 'secondary'}>
|
||||||
<AlertDialogContent>
|
{court.isActive ? 'Active' : 'Inactive'}
|
||||||
<AlertDialogHeader>
|
</Badge>
|
||||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
<div className='flex gap-1'>
|
||||||
Are you sure you want to delete {courtToDelete ? `"${courtToDelete.name}"` : 'this court'}?
|
<Button
|
||||||
This action cannot be undone.
|
size='sm'
|
||||||
</AlertDialogDescription>
|
variant='outline'
|
||||||
</AlertDialogHeader>
|
onClick={() => handleEdit(court)}
|
||||||
<AlertDialogFooter>
|
disabled={editing === court.id}
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
>
|
||||||
<AlertDialogAction
|
<Edit className='h-4 w-4' />
|
||||||
onClick={confirmDeleteCourt}
|
</Button>
|
||||||
className='bg-destructive hover:bg-destructive/90'
|
<Button
|
||||||
>
|
size='sm'
|
||||||
Delete
|
variant='outline'
|
||||||
</AlertDialogAction>
|
onClick={() => openDeleteDialog(court)}
|
||||||
</AlertDialogFooter>
|
disabled={deleting === court.id}
|
||||||
</AlertDialogContent>
|
className='text-red-600 hover:text-red-700'
|
||||||
</AlertDialog>
|
>
|
||||||
</Card>
|
{deleting === court.id ? (
|
||||||
|
<RefreshCw className='h-4 w-4 animate-spin' />
|
||||||
|
) : (
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
Are you sure you want to delete{' '}
|
||||||
|
{courtToDelete ? `"${courtToDelete.name}"` : 'this court'}? This action cannot
|
||||||
|
be undone.
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDeleteCourt}
|
||||||
|
className='bg-destructive hover:bg-destructive/90'
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value='closures'>
|
||||||
|
<AdminBlocksManagement />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ interface User {
|
|||||||
email: string;
|
email: string;
|
||||||
role: 'user' | 'admin';
|
role: 'user' | 'admin';
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
lastBookingDate: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserFormData {
|
interface UserFormData {
|
||||||
@@ -387,58 +388,151 @@ export function AdminUserManagement() {
|
|||||||
<CardTitle>All Users ({filteredUsers.length})</CardTitle>
|
<CardTitle>All Users ({filteredUsers.length})</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
{/* Desktop Table */}
|
||||||
<TableHeader>
|
<div className='hidden md:block'>
|
||||||
<TableRow>
|
<Table>
|
||||||
<TableHead>Name</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Email</TableHead>
|
<TableRow>
|
||||||
<TableHead>Role</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Created</TableHead>
|
<TableHead>Email</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Role</TableHead>
|
||||||
</TableRow>
|
<TableHead>Created</TableHead>
|
||||||
</TableHeader>
|
<TableHead>Last Played</TableHead>
|
||||||
<TableBody>
|
<TableHead>Actions</TableHead>
|
||||||
{filteredUsers.map((user) => (
|
</TableRow>
|
||||||
<TableRow key={user.id}>
|
</TableHeader>
|
||||||
<TableCell className='font-medium'>
|
<TableBody>
|
||||||
{user.name} {user.surname}
|
{filteredUsers.map((user) => (
|
||||||
</TableCell>
|
<TableRow key={user.id}>
|
||||||
<TableCell>
|
<TableCell className='font-medium'>
|
||||||
<div className='flex items-center gap-2'>
|
{user.name} {user.surname}
|
||||||
<Mail className='h-4 w-4 text-gray-500' />
|
</TableCell>
|
||||||
{user.email}
|
<TableCell>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Mail className='h-4 w-4 text-gray-500' />
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Calendar className='h-4 w-4 text-gray-500' />
|
||||||
|
{new Date(user.createdAt).toLocaleDateString('en-IE')}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.lastBookingDate ? (
|
||||||
|
<div className='flex items-center gap-2'>
|
||||||
|
<Calendar className='h-4 w-4 text-green-600' />
|
||||||
|
{new Date(user.lastBookingDate).toLocaleDateString('en-IE')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='flex items-center gap-2 text-gray-500'>
|
||||||
|
<Calendar className='h-4 w-4' />
|
||||||
|
Never played
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => openEditDialog(user)}
|
||||||
|
>
|
||||||
|
<Edit className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => openDeleteDialog(user)}
|
||||||
|
className='text-red-600 hover:text-red-700'
|
||||||
|
>
|
||||||
|
<Trash2 className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Card Layout */}
|
||||||
|
<div className='md:hidden space-y-4'>
|
||||||
|
{filteredUsers.map((user) => (
|
||||||
|
<Card key={user.id} className='p-4'>
|
||||||
|
<div className='space-y-3'>
|
||||||
|
<div className='flex items-start justify-between'>
|
||||||
|
<div>
|
||||||
|
<h3 className='font-medium text-sm'>
|
||||||
|
{user.name} {user.surname}
|
||||||
|
</h3>
|
||||||
|
<p className='text-xs text-muted-foreground flex items-center gap-1 mt-1'>
|
||||||
|
<Mail className='h-3 w-3' />
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
<Badge
|
||||||
<TableCell>
|
variant={user.role === 'admin' ? 'default' : 'secondary'}
|
||||||
<Badge variant={user.role === 'admin' ? 'default' : 'secondary'}>
|
className='text-xs'
|
||||||
|
>
|
||||||
{user.role}
|
{user.role}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</div>
|
||||||
<TableCell>
|
|
||||||
<div className='flex items-center gap-2'>
|
<div className='grid grid-cols-2 gap-3 text-xs'>
|
||||||
<Calendar className='h-4 w-4 text-gray-500' />
|
<div>
|
||||||
{new Date(user.createdAt).toLocaleDateString('en-IE')}
|
<span className='text-muted-foreground block'>Created</span>
|
||||||
|
<span className='flex items-center gap-1 mt-1'>
|
||||||
|
<Calendar className='h-3 w-3 text-gray-500' />
|
||||||
|
{new Date(user.createdAt).toLocaleDateString('en-IE')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
<div>
|
||||||
<TableCell>
|
<span className='text-muted-foreground block'>Last Played</span>
|
||||||
<div className='flex gap-2'>
|
{user.lastBookingDate ? (
|
||||||
<Button variant='outline' size='sm' onClick={() => openEditDialog(user)}>
|
<span className='flex items-center gap-1 mt-1'>
|
||||||
<Edit className='h-4 w-4' />
|
<Calendar className='h-3 w-3 text-green-600' />
|
||||||
</Button>
|
{new Date(user.lastBookingDate).toLocaleDateString('en-IE')}
|
||||||
<Button
|
</span>
|
||||||
variant='outline'
|
) : (
|
||||||
size='sm'
|
<span className='flex items-center gap-1 mt-1 text-gray-500'>
|
||||||
onClick={() => openDeleteDialog(user)}
|
<Calendar className='h-3 w-3' />
|
||||||
className='text-red-600 hover:text-red-700'
|
Never played
|
||||||
>
|
</span>
|
||||||
<Trash2 className='h-4 w-4' />
|
)}
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</div>
|
||||||
</TableRow>
|
|
||||||
))}
|
<div className='flex gap-2 pt-2'>
|
||||||
</TableBody>
|
<Button
|
||||||
</Table>
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => openEditDialog(user)}
|
||||||
|
className='flex-1'
|
||||||
|
>
|
||||||
|
<Edit className='h-3 w-3 mr-1' />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => openDeleteDialog(user)}
|
||||||
|
className='flex-1 text-red-600 hover:text-red-700'
|
||||||
|
>
|
||||||
|
<Trash2 className='h-3 w-3 mr-1' />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
{filteredUsers.length === 0 && (
|
{filteredUsers.length === 0 && (
|
||||||
<div className='text-center py-8 text-gray-500'>
|
<div className='text-center py-8 text-gray-500'>
|
||||||
No users found matching your search criteria
|
No users found matching your search criteria
|
||||||
|
|||||||
@@ -5,7 +5,26 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Users, Calendar, Settings, BarChart3, Bell, Shield, Clock, MapPin, Activity, LogOut } from 'lucide-react';
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
Calendar,
|
||||||
|
Settings,
|
||||||
|
BarChart3,
|
||||||
|
Bell,
|
||||||
|
Shield,
|
||||||
|
Clock,
|
||||||
|
MapPin,
|
||||||
|
Activity,
|
||||||
|
LogOut,
|
||||||
|
ArrowLeft,
|
||||||
|
ChevronDown,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { AdminUserManagement } from './AdminUserManagement';
|
import { AdminUserManagement } from './AdminUserManagement';
|
||||||
import { AdminAnnouncementManagement } from './AdminAnnouncementManagement';
|
import { AdminAnnouncementManagement } from './AdminAnnouncementManagement';
|
||||||
@@ -35,6 +54,7 @@ interface RecentBooking {
|
|||||||
|
|
||||||
export function AdminDashboard() {
|
export function AdminDashboard() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const [activeTab, setActiveTab] = useState('bookings');
|
||||||
const [stats, setStats] = useState<AdminStats>({
|
const [stats, setStats] = useState<AdminStats>({
|
||||||
totalUsers: 0,
|
totalUsers: 0,
|
||||||
activeCourts: 0,
|
activeCourts: 0,
|
||||||
@@ -79,13 +99,23 @@ export function AdminDashboard() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className='bg-card border-b border-border'>
|
<header className='bg-card border-b border-border'>
|
||||||
<div className='container mx-auto px-4'>
|
<div className='container mx-auto px-4'>
|
||||||
<div className='flex items-center justify-between h-16'>
|
{/* Desktop Layout */}
|
||||||
|
<div className='hidden md:flex items-center justify-between h-16'>
|
||||||
<div className='flex items-center space-x-4'>
|
<div className='flex items-center space-x-4'>
|
||||||
<Shield className='h-6 w-6 text-blue-600 dark:text-blue-400' />
|
<Shield className='h-6 w-6 text-blue-600 dark:text-blue-400' />
|
||||||
<h1 className='text-xl font-semibold text-foreground'>Admin Dashboard</h1>
|
<h1 className='text-xl font-semibold text-foreground'>Admin Dashboard</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='flex items-center space-x-4'>
|
<div className='flex items-center space-x-4'>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
className='flex items-center gap-2'
|
||||||
|
>
|
||||||
|
<ArrowLeft className='h-4 w-4' />
|
||||||
|
Back to Booking
|
||||||
|
</Button>
|
||||||
<Badge variant='secondary'>Administrator</Badge>
|
<Badge variant='secondary'>Administrator</Badge>
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
<Button variant='ghost' size='sm' onClick={handleLogout}>
|
<Button variant='ghost' size='sm' onClick={handleLogout}>
|
||||||
@@ -94,6 +124,44 @@ export function AdminDashboard() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Layout */}
|
||||||
|
<div className='md:hidden py-3'>
|
||||||
|
{/* Top row */}
|
||||||
|
<div className='flex items-center justify-between mb-3'>
|
||||||
|
<div className='flex items-center space-x-2 min-w-0 flex-1'>
|
||||||
|
<Shield className='h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0' />
|
||||||
|
<h1 className='text-lg font-semibold text-foreground truncate'>Admin Dashboard</h1>
|
||||||
|
<Badge variant='secondary' className='flex-shrink-0'>
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<ModeToggle />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom row */}
|
||||||
|
<div className='flex items-center justify-between gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => router.push('/dashboard')}
|
||||||
|
className='flex items-center gap-1 px-3 py-2 min-h-[36px] touch-manipulation'
|
||||||
|
>
|
||||||
|
<ArrowLeft className='h-4 w-4' />
|
||||||
|
<span className='text-xs font-medium'>Booking</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={handleLogout}
|
||||||
|
className='flex items-center gap-1 px-3 py-2 min-h-[36px] touch-manipulation'
|
||||||
|
>
|
||||||
|
<LogOut className='h-4 w-4' />
|
||||||
|
<span className='text-xs font-medium'>Logout</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -146,8 +214,9 @@ export function AdminDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin Tabs */}
|
{/* Admin Tabs */}
|
||||||
<Tabs defaultValue='bookings' className='space-y-6'>
|
<Tabs value={activeTab} onValueChange={setActiveTab} className='space-y-6'>
|
||||||
<TabsList className='grid w-full grid-cols-6'>
|
{/* Desktop Tabs */}
|
||||||
|
<TabsList className='hidden md:grid w-full grid-cols-6'>
|
||||||
<TabsTrigger value='bookings'>Bookings</TabsTrigger>
|
<TabsTrigger value='bookings'>Bookings</TabsTrigger>
|
||||||
<TabsTrigger value='users'>Users</TabsTrigger>
|
<TabsTrigger value='users'>Users</TabsTrigger>
|
||||||
<TabsTrigger value='courts'>Courts</TabsTrigger>
|
<TabsTrigger value='courts'>Courts</TabsTrigger>
|
||||||
@@ -155,6 +224,48 @@ export function AdminDashboard() {
|
|||||||
<TabsTrigger value='announcements'>Announcements</TabsTrigger>
|
<TabsTrigger value='announcements'>Announcements</TabsTrigger>
|
||||||
<TabsTrigger value='logs'>Logs</TabsTrigger>
|
<TabsTrigger value='logs'>Logs</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
{/* Mobile Dropdown */}
|
||||||
|
<div className='md:hidden'>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant='outline' className='w-full justify-between'>
|
||||||
|
{activeTab === 'bookings' && 'Bookings'}
|
||||||
|
{activeTab === 'users' && 'Users'}
|
||||||
|
{activeTab === 'courts' && 'Courts'}
|
||||||
|
{activeTab === 'settings' && 'Settings'}
|
||||||
|
{activeTab === 'announcements' && 'Announcements'}
|
||||||
|
{activeTab === 'logs' && 'Logs'}
|
||||||
|
<ChevronDown className='h-4 w-4' />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className='w-full'>
|
||||||
|
<DropdownMenuItem onClick={() => setActiveTab('bookings')}>
|
||||||
|
<Calendar className='h-4 w-4 mr-2' />
|
||||||
|
Bookings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setActiveTab('users')}>
|
||||||
|
<Users className='h-4 w-4 mr-2' />
|
||||||
|
Users
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setActiveTab('courts')}>
|
||||||
|
<MapPin className='h-4 w-4 mr-2' />
|
||||||
|
Courts
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setActiveTab('settings')}>
|
||||||
|
<Settings className='h-4 w-4 mr-2' />
|
||||||
|
Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setActiveTab('announcements')}>
|
||||||
|
<Bell className='h-4 w-4 mr-2' />
|
||||||
|
Announcements
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setActiveTab('logs')}>
|
||||||
|
<Activity className='h-4 w-4 mr-2' />
|
||||||
|
Logs
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
<TabsContent value='bookings'>
|
<TabsContent value='bookings'>
|
||||||
<AdminRecentBookings />
|
<AdminRecentBookings />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -163,7 +274,7 @@ export function AdminDashboard() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value='courts'>
|
<TabsContent value='courts'>
|
||||||
<AdminCourtManagement />
|
<AdminCourtManagement />
|
||||||
</TabsContent>{' '}
|
</TabsContent>
|
||||||
<TabsContent value='settings'>
|
<TabsContent value='settings'>
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<AdminSettingsManagement />
|
<AdminSettingsManagement />
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||||
import { Calendar, Clock, MapPin, ChevronLeft, ChevronRight, Users, User } from 'lucide-react';
|
import { Calendar, Clock, MapPin, ChevronLeft, ChevronRight, Users, User, Ban } from 'lucide-react';
|
||||||
import { useToast } from '@/hooks/use-toast';
|
import { useToast } from '@/hooks/use-toast';
|
||||||
|
|
||||||
interface Court {
|
interface Court {
|
||||||
@@ -40,6 +40,8 @@ interface BookingSlot {
|
|||||||
bookingId?: string;
|
bookingId?: string;
|
||||||
bookedBy?: string;
|
bookedBy?: string;
|
||||||
partner?: string;
|
partner?: string;
|
||||||
|
blocked?: boolean;
|
||||||
|
blockReason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TimeSlot {
|
interface TimeSlot {
|
||||||
@@ -50,6 +52,15 @@ interface TimeSlot {
|
|||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CourtBlock {
|
||||||
|
id: string;
|
||||||
|
courtId: string | null;
|
||||||
|
date: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Settings {
|
interface Settings {
|
||||||
booking_window_days: string;
|
booking_window_days: string;
|
||||||
booking_start_time: string;
|
booking_start_time: string;
|
||||||
@@ -63,6 +74,7 @@ export function EnhancedBookingCalendar() {
|
|||||||
const [bookings, setBookings] = useState<Booking[]>([]);
|
const [bookings, setBookings] = useState<Booking[]>([]);
|
||||||
const [bookingSlots, setBookingSlots] = useState<BookingSlot[]>([]);
|
const [bookingSlots, setBookingSlots] = useState<BookingSlot[]>([]);
|
||||||
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
|
const [timeSlots, setTimeSlots] = useState<TimeSlot[]>([]);
|
||||||
|
const [blocks, setBlocks] = useState<CourtBlock[]>([]);
|
||||||
const [settings, setSettings] = useState<Settings | null>(null);
|
const [settings, setSettings] = useState<Settings | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [partnerName, setPartnerName] = useState('');
|
const [partnerName, setPartnerName] = useState('');
|
||||||
@@ -75,6 +87,7 @@ export function EnhancedBookingCalendar() {
|
|||||||
fetchSettings();
|
fetchSettings();
|
||||||
fetchCourts();
|
fetchCourts();
|
||||||
fetchTimeSlots();
|
fetchTimeSlots();
|
||||||
|
fetchBlocks();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -156,6 +169,20 @@ export function EnhancedBookingCalendar() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Fetch court blocks (closures)
|
||||||
|
const fetchBlocks = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/blocks');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setBlocks(data.blocks || []);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching blocks:', error);
|
||||||
|
// If blocks fetch fails, just proceed without block data
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchBookings = async () => {
|
const fetchBookings = async () => {
|
||||||
try {
|
try {
|
||||||
const dateStr = selectedDate.toISOString().split('T')[0];
|
const dateStr = selectedDate.toISOString().split('T')[0];
|
||||||
@@ -236,6 +263,9 @@ export function EnhancedBookingCalendar() {
|
|||||||
const timeSlots = generateTimeSlots();
|
const timeSlots = generateTimeSlots();
|
||||||
const slots: BookingSlot[] = [];
|
const slots: BookingSlot[] = [];
|
||||||
|
|
||||||
|
// Get blocks for the selected date
|
||||||
|
const dateBlocks = blocks.filter((block) => block.date === dateStr);
|
||||||
|
|
||||||
courts.forEach((court) => {
|
courts.forEach((court) => {
|
||||||
timeSlots.forEach((time) => {
|
timeSlots.forEach((time) => {
|
||||||
const existingBooking = existingBookings.find(
|
const existingBooking = existingBookings.find(
|
||||||
@@ -246,6 +276,17 @@ export function EnhancedBookingCalendar() {
|
|||||||
booking.status === 'active'
|
booking.status === 'active'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Check if this time slot is blocked
|
||||||
|
const slotHour = parseInt(time.split(':')[0]);
|
||||||
|
const blockingBlock = dateBlocks.find((block) => {
|
||||||
|
const blockStartHour = parseInt(block.startTime.split(':')[0]);
|
||||||
|
const blockEndHour = parseInt(block.endTime.split(':')[0]);
|
||||||
|
const isTimeInBlock = slotHour >= blockStartHour && slotHour < blockEndHour;
|
||||||
|
// Block applies if it's for this specific court or for all courts (courtId null/undefined/empty)
|
||||||
|
const appliesToCourt = !block.courtId || block.courtId === court.id;
|
||||||
|
return isTimeInBlock && appliesToCourt;
|
||||||
|
});
|
||||||
|
|
||||||
const bookedBy = existingBooking?.user
|
const bookedBy = existingBooking?.user
|
||||||
? `${existingBooking.user.name} ${existingBooking.user.surname}`
|
? `${existingBooking.user.name} ${existingBooking.user.surname}`
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -256,10 +297,12 @@ export function EnhancedBookingCalendar() {
|
|||||||
time,
|
time,
|
||||||
courtId: court.id,
|
courtId: court.id,
|
||||||
courtName: court.name,
|
courtName: court.name,
|
||||||
available: !existingBooking,
|
available: !existingBooking && !blockingBlock,
|
||||||
bookingId: existingBooking?.id,
|
bookingId: existingBooking?.id,
|
||||||
bookedBy,
|
bookedBy,
|
||||||
partner,
|
partner,
|
||||||
|
blocked: !!blockingBlock,
|
||||||
|
blockReason: blockingBlock?.reason,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -302,6 +345,15 @@ export function EnhancedBookingCalendar() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSlotClick = (slot: BookingSlot) => {
|
const handleSlotClick = (slot: BookingSlot) => {
|
||||||
|
if (slot.blocked) {
|
||||||
|
toast({
|
||||||
|
title: 'Slot Blocked',
|
||||||
|
description: slot.blockReason || 'This slot is blocked for an event',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!slot.available) return;
|
if (!slot.available) return;
|
||||||
|
|
||||||
// Double-check that this day is actually bookable
|
// Double-check that this day is actually bookable
|
||||||
@@ -570,7 +622,9 @@ export function EnhancedBookingCalendar() {
|
|||||||
<div
|
<div
|
||||||
key={`${slot.courtId}-${slot.time}`}
|
key={`${slot.courtId}-${slot.time}`}
|
||||||
className={`p-3 border rounded-lg transition-all duration-200 ${
|
className={`p-3 border rounded-lg transition-all duration-200 ${
|
||||||
slot.available
|
slot.blocked
|
||||||
|
? 'border-orange-300 bg-orange-50 cursor-not-allowed dark:border-orange-700 dark:bg-orange-950/50'
|
||||||
|
: slot.available
|
||||||
? 'border-green-200 bg-green-50 hover:bg-green-100 hover:shadow-sm cursor-pointer dark:border-green-700 dark:bg-green-950 dark:hover:bg-green-900'
|
? 'border-green-200 bg-green-50 hover:bg-green-100 hover:shadow-sm cursor-pointer dark:border-green-700 dark:bg-green-950 dark:hover:bg-green-900'
|
||||||
: 'border-muted bg-muted/50 cursor-not-allowed opacity-75 hover:opacity-100'
|
: 'border-muted bg-muted/50 cursor-not-allowed opacity-75 hover:opacity-100'
|
||||||
}`}
|
}`}
|
||||||
@@ -582,39 +636,59 @@ export function EnhancedBookingCalendar() {
|
|||||||
<MapPin className='h-4 w-4' />
|
<MapPin className='h-4 w-4' />
|
||||||
{slot.courtName}
|
{slot.courtName}
|
||||||
</div>
|
</div>
|
||||||
{!slot.available && slot.bookedBy && (
|
{slot.blocked && (
|
||||||
<div className='space-y-1'>
|
<div className='flex items-center gap-2 text-xs text-orange-600 dark:text-orange-400'>
|
||||||
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
|
<Ban className='h-3 w-3' />
|
||||||
<Users className='h-3 w-3' />
|
{slot.blockReason || 'Blocked'}
|
||||||
Booked by {slot.bookedBy}
|
</div>
|
||||||
</div>
|
)}
|
||||||
{slot.partner && (
|
{!slot.blocked &&
|
||||||
<div className='flex items-center gap-2 text-xs text-orange-600 dark:text-orange-400'>
|
!slot.available &&
|
||||||
<User className='h-3 w-3' />
|
slot.bookedBy && (
|
||||||
Playing with: {slot.partner}
|
<div className='space-y-1'>
|
||||||
|
<div className='flex items-center gap-2 text-xs text-muted-foreground'>
|
||||||
|
<Users className='h-3 w-3' />
|
||||||
|
Booked by {slot.bookedBy}
|
||||||
</div>
|
</div>
|
||||||
)}
|
{slot.partner && (
|
||||||
</div>
|
<div className='flex items-center gap-2 text-xs text-orange-600 dark:text-orange-400'>
|
||||||
)}
|
<User className='h-3 w-3' />
|
||||||
{!slot.available && !slot.bookedBy && (
|
Playing with: {slot.partner}
|
||||||
<div className='text-xs text-muted-foreground'>
|
</div>
|
||||||
Already booked
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!slot.blocked &&
|
||||||
|
!slot.available &&
|
||||||
|
!slot.bookedBy && (
|
||||||
|
<div className='text-xs text-muted-foreground'>
|
||||||
|
Already booked
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
size='sm'
|
size='sm'
|
||||||
disabled={!slot.available}
|
disabled={!slot.available || slot.blocked}
|
||||||
variant={
|
variant={
|
||||||
slot.available ? 'default' : 'secondary'
|
slot.blocked
|
||||||
|
? 'outline'
|
||||||
|
: slot.available
|
||||||
|
? 'default'
|
||||||
|
: 'secondary'
|
||||||
}
|
}
|
||||||
className={
|
className={
|
||||||
slot.available
|
slot.blocked
|
||||||
|
? 'border-orange-400 text-orange-600 dark:border-orange-600 dark:text-orange-400 cursor-not-allowed'
|
||||||
|
: slot.available
|
||||||
? 'bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 border-0'
|
? 'bg-green-600 hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600 border-0'
|
||||||
: 'opacity-50 cursor-not-allowed'
|
: 'opacity-50 cursor-not-allowed'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{slot.available ? 'Book' : 'Booked'}
|
{slot.blocked
|
||||||
|
? 'Blocked'
|
||||||
|
: slot.available
|
||||||
|
? 'Book'
|
||||||
|
: 'Booked'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ export function DashboardHeader({ user }: DashboardHeaderProps) {
|
|||||||
return (
|
return (
|
||||||
<header className='bg-background/80 backdrop-blur-md border-b border-border sticky top-0 z-50'>
|
<header className='bg-background/80 backdrop-blur-md border-b border-border sticky top-0 z-50'>
|
||||||
<div className='container mx-auto px-4'>
|
<div className='container mx-auto px-4'>
|
||||||
<div className='flex items-center justify-between h-16'>
|
{/* Desktop Layout */}
|
||||||
|
<div className='hidden md:flex items-center justify-between h-16'>
|
||||||
<div className='flex items-center space-x-4'>
|
<div className='flex items-center space-x-4'>
|
||||||
<div className='flex items-center space-x-2'>
|
<div className='flex items-center space-x-2'>
|
||||||
<Calendar className='h-6 w-6 text-primary' />
|
<Calendar className='h-6 w-6 text-primary' />
|
||||||
@@ -131,6 +132,73 @@ export function DashboardHeader({ user }: DashboardHeaderProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Layout */}
|
||||||
|
<div className='md:hidden py-3'>
|
||||||
|
{/* Top row */}
|
||||||
|
<div className='flex items-center justify-between mb-3'>
|
||||||
|
<div className='flex items-center space-x-2 min-w-0 flex-1'>
|
||||||
|
<Calendar className='h-5 w-5 text-primary flex-shrink-0' />
|
||||||
|
<h1 className='text-lg font-bold text-foreground truncate'>
|
||||||
|
{appConfig?.clubName || 'TT Booking'}
|
||||||
|
</h1>
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<Badge variant='secondary' className='flex-shrink-0'>
|
||||||
|
Admin
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex items-center space-x-2 flex-shrink-0'>
|
||||||
|
<NotificationBell unreadCount={unreadCount} onClick={() => setShowAnnouncements(true)} />
|
||||||
|
<ModeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom row */}
|
||||||
|
<div className='flex items-center justify-between gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setShowUserProfile(true)}
|
||||||
|
className='flex items-center space-x-2 min-w-0 flex-1 justify-start'
|
||||||
|
>
|
||||||
|
<User className='h-4 w-4 text-muted-foreground flex-shrink-0' />
|
||||||
|
<span className='text-sm text-foreground truncate'>
|
||||||
|
{user.name && user.surname ? `${user.name} ${user.surname}` : user.email.split('@')[0]}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className='flex items-center gap-2 flex-shrink-0'>
|
||||||
|
{user.role === 'admin' && (
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => {
|
||||||
|
console.log('Admin button clicked');
|
||||||
|
router.push('/admin');
|
||||||
|
}}
|
||||||
|
className='flex items-center gap-1 px-3 py-2 min-h-[36px] touch-manipulation'
|
||||||
|
title='Admin Panel'
|
||||||
|
>
|
||||||
|
<Settings className='h-4 w-4' />
|
||||||
|
<span className='text-xs font-medium'>Admin</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={handleLogout}
|
||||||
|
disabled={isLoggingOut}
|
||||||
|
className='flex items-center gap-1 px-2'
|
||||||
|
title={isLoggingOut ? 'Logging out...' : 'Logout'}
|
||||||
|
>
|
||||||
|
<LogOut className='h-4 w-4' />
|
||||||
|
<span className='text-xs'>{isLoggingOut ? 'Out' : 'Logout'}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Announcements Modal */}
|
{/* Announcements Modal */}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Required Environment Variables for TT Booking
|
||||||
|
|
||||||
|
# Generate with: openssl rand -base64 32
|
||||||
|
NEXTAUTH_SECRET=your-secret-key-here
|
||||||
|
|
||||||
|
# Your application URL
|
||||||
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# Admin user credentials (change these!)
|
||||||
|
ADMIN_EMAIL=admin@your-domain.com
|
||||||
|
ADMIN_PASSWORD=your-secure-password
|
||||||
|
|
||||||
|
# Optional: Email configuration for notifications
|
||||||
|
EMAIL_USER=your-email@gmail.com
|
||||||
|
EMAIL_PASSWORD=your-gmail-app-password
|
||||||
|
|
||||||
|
# Optional: Rate limiting (defaults shown)
|
||||||
|
RATE_LIMIT_MAX=100
|
||||||
|
RATE_LIMIT_WINDOW=900000
|
||||||
|
|
||||||
|
# Optional: Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Sample Docker Compose for TT Booking
|
||||||
|
# Copy this file and create a .env file with your settings
|
||||||
|
|
||||||
|
services:
|
||||||
|
tt-booking:
|
||||||
|
image: your-registry/tt-booking:latest
|
||||||
|
container_name: tt-booking
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- NEXTAUTH_URL=http://localhost:3000
|
||||||
|
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||||||
|
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@tabletennis.com}
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin123}
|
||||||
|
# Optional email settings
|
||||||
|
- EMAIL_USER=${EMAIL_USER}
|
||||||
|
- EMAIL_PASSWORD=${EMAIL_PASSWORD}
|
||||||
|
# Optional rate limiting
|
||||||
|
- RATE_LIMIT_MAX=${RATE_LIMIT_MAX:-100}
|
||||||
|
- RATE_LIMIT_WINDOW=${RATE_LIMIT_WINDOW:-900000}
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./backups:/app/backups
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# Optional: Automated backup service
|
||||||
|
backup:
|
||||||
|
image: alpine:latest
|
||||||
|
container_name: tt-booking-backup
|
||||||
|
volumes:
|
||||||
|
- ./data:/data:ro
|
||||||
|
- ./backups:/backups
|
||||||
|
environment:
|
||||||
|
- TZ=UTC
|
||||||
|
command: >
|
||||||
|
sh -c "
|
||||||
|
apk add --no-cache tzdata &&
|
||||||
|
while true; do
|
||||||
|
timestamp=$$(date +%Y%m%d-%H%M%S)
|
||||||
|
echo \"Creating backup at $$timestamp\"
|
||||||
|
cp /data/sqlite.db \"/backups/sqlite-$$timestamp.db\" 2>/dev/null || echo 'No database file found yet'
|
||||||
|
# Keep backups for 7 days
|
||||||
|
find /backups -name 'sqlite-*.db' -mtime +7 -delete 2>/dev/null || true
|
||||||
|
echo \"Backup completed, sleeping for 24 hours\"
|
||||||
|
sleep 86400
|
||||||
|
done"
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- tt-booking
|
||||||
@@ -14,77 +14,64 @@ if [ ! -f .env.production ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create necessary directories
|
|
||||||
echo "📁 Creating necessary directories..."
|
|
||||||
mkdir -p data backups logs
|
|
||||||
|
|
||||||
# Set proper permissions
|
|
||||||
echo "🔒 Setting directory permissions..."
|
|
||||||
chmod 755 data backups logs
|
|
||||||
|
|
||||||
# Pull latest changes (if using git)
|
# Pull latest changes (if using git)
|
||||||
if [ -d ".git" ]; then
|
if [ -d ".git" ]; then
|
||||||
echo "📦 Pulling latest changes..."
|
echo "📦 Pulling latest changes..."
|
||||||
git pull origin main || echo "⚠️ Git pull failed or not needed"
|
git pull origin main || echo "⚠️ Git pull failed or not needed"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Setup database
|
|
||||||
echo "🛠️ Setting up the database..."
|
|
||||||
npx tsx scripts/setup-database.ts
|
|
||||||
|
|
||||||
# Build and deploy with Docker Compose
|
# Build and deploy with Docker Compose
|
||||||
echo "🐳 Building and starting Docker containers..."
|
echo "🐳 Building and deploying containers..."
|
||||||
|
|
||||||
# Stop existing containers
|
# Stop existing containers
|
||||||
docker-compose -f docker-compose.production.yml down || echo "No existing containers to stop"
|
docker compose -f docker-compose.production.yml down || echo "No existing containers to stop"
|
||||||
|
|
||||||
# Build and start containers
|
# Build and start containers (database initialization is automated)
|
||||||
docker-compose -f docker-compose.production.yml up -d --build
|
docker compose -f docker-compose.production.yml up -d --build
|
||||||
|
|
||||||
# Wait for containers to be healthy
|
# Wait for health check to pass (container has built-in health checks)
|
||||||
echo "⏳ Waiting for containers to be healthy..."
|
echo "⏳ Waiting for application to be ready..."
|
||||||
sleep 30
|
timeout=60
|
||||||
|
counter=0
|
||||||
# Check health
|
while [ $counter -lt $timeout ]; do
|
||||||
echo "🔍 Checking application health..."
|
if docker compose -f docker-compose.production.yml ps tt-booking | grep -q "healthy"; then
|
||||||
for i in {1..10}; do
|
|
||||||
if curl -f http://localhost:3000/api/health >/dev/null 2>&1; then
|
|
||||||
echo "✅ Application is healthy!"
|
echo "✅ Application is healthy!"
|
||||||
break
|
break
|
||||||
elif [ $i -eq 10 ]; then
|
elif [ $counter -eq $((timeout-10)) ]; then
|
||||||
echo "❌ Application health check failed after 10 attempts"
|
echo "❌ Application failed to become healthy within ${timeout}s"
|
||||||
echo "📋 Container logs:"
|
echo "📋 Container logs:"
|
||||||
docker-compose -f docker-compose.production.yml logs tt-booking
|
docker compose -f docker-compose.production.yml logs --tail=30 tt-booking
|
||||||
exit 1
|
exit 1
|
||||||
else
|
else
|
||||||
echo "⏳ Attempt $i/10: Application not ready yet, waiting..."
|
echo "⏳ Waiting for health check... (${counter}s/${timeout}s)"
|
||||||
sleep 10
|
sleep 5
|
||||||
|
counter=$((counter+5))
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Show running containers
|
# Show deployment status
|
||||||
echo "📊 Running containers:"
|
echo "📊 Deployment Status:"
|
||||||
docker-compose -f docker-compose.production.yml ps
|
docker compose -f docker-compose.production.yml ps tt-booking
|
||||||
|
|
||||||
# Show logs
|
|
||||||
echo "📋 Recent application logs:"
|
echo "📋 Recent application logs:"
|
||||||
docker-compose -f docker-compose.production.yml logs --tail=20 tt-booking
|
docker compose -f docker-compose.production.yml logs --tail=10 tt-booking
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "🎉 Deployment completed successfully!"
|
echo "🎉 Deployment completed successfully!"
|
||||||
echo ""
|
echo ""
|
||||||
echo "📊 Application Status:"
|
echo "📊 Application Details:"
|
||||||
echo " • URL: https://lcc-tt-booking.mikicvi.com"
|
echo " - URL: https://lcc-tt-booking.mikicvi.com"
|
||||||
echo " • Health Check: http://localhost:3000/api/health"
|
echo " - Local: http://localhost:3036"
|
||||||
echo " • Container Status: $(docker-compose -f docker-compose.production.yml ps -q tt-booking | xargs docker inspect -f '{{.State.Status}}')"
|
echo " - Health: http://localhost:3036/api/health"
|
||||||
echo ""
|
echo ""
|
||||||
echo "🔧 Useful commands:"
|
echo "🔧 Management commands:"
|
||||||
echo " • View logs: docker-compose -f docker-compose.production.yml logs -f tt-booking"
|
echo " - Logs: docker compose -f docker-compose.production.yml logs -f tt-booking"
|
||||||
echo " • Restart: docker-compose -f docker-compose.production.yml restart tt-booking"
|
echo " - Restart: docker compose -f docker-compose.production.yml restart tt-booking"
|
||||||
echo " • Stop: docker-compose -f docker-compose.production.yml down"
|
echo " - Stop: docker compose -f docker-compose.production.yml down"
|
||||||
|
echo " - Shell: docker compose -f docker-compose.production.yml exec tt-booking sh"
|
||||||
echo ""
|
echo ""
|
||||||
echo "⚠️ Don't forget to:"
|
echo "⚠️ Post-deployment checklist:"
|
||||||
echo " 1. Set up Cloudflare Tunnel to expose your application"
|
echo " - Cloudflare Tunnel is configured and running"
|
||||||
echo " 2. Update your .env.production with real email credentials"
|
echo " - Admin password changed from default"
|
||||||
echo " 3. Change the default admin password"
|
echo " - Email settings configured in .env.production"
|
||||||
echo ""
|
echo ""
|
||||||
@@ -1,26 +1,13 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
tt-booking:
|
tt-booking:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.production
|
||||||
container_name: lcc-tt-booking
|
container_name: lcc-tt-booking
|
||||||
ports:
|
ports:
|
||||||
- '3000:3000'
|
- '3036:3000'
|
||||||
environment:
|
env_file:
|
||||||
- NODE_ENV=production
|
- .env.production
|
||||||
- DATABASE_URL=/app/data/sqlite.db
|
|
||||||
- NEXTAUTH_URL=https://lcc-tt-booking.mikicvi.com
|
|
||||||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
|
||||||
- EMAIL_USER=${EMAIL_USER}
|
|
||||||
- EMAIL_PASSWORD=${EMAIL_PASSWORD}
|
|
||||||
- ADMIN_EMAIL=${ADMIN_EMAIL}
|
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
|
||||||
- PORT=3000
|
|
||||||
- RATE_LIMIT_MAX=${RATE_LIMIT_MAX:-100}
|
|
||||||
- RATE_LIMIT_WINDOW=${RATE_LIMIT_WINDOW:-900000}
|
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-info}
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./backups:/app/backups
|
- ./backups:/app/backups
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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,76 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 Starting TT Booking Production..."
|
||||||
|
|
||||||
|
# Create required directories if they don't exist
|
||||||
|
echo "📁 Ensuring directories exist..."
|
||||||
|
mkdir -p /app/data /app/backups /app/logs
|
||||||
|
chmod 755 /app/data /app/backups /app/logs
|
||||||
|
|
||||||
|
# Set defaults for environment variables
|
||||||
|
export DATABASE_URL="${DATABASE_URL:-/app/data/sqlite.db}"
|
||||||
|
export NODE_ENV="${NODE_ENV:-production}"
|
||||||
|
export ADMIN_EMAIL="${ADMIN_EMAIL:-admin@tabletennis.com}"
|
||||||
|
export ADMIN_PASSWORD="${ADMIN_PASSWORD:-admin123}"
|
||||||
|
|
||||||
|
# Validate required environment variables
|
||||||
|
if [ -z "$NEXTAUTH_SECRET" ]; then
|
||||||
|
echo "❌ NEXTAUTH_SECRET is required but not set"
|
||||||
|
echo "💡 Generate one with: openssl rand -base64 32"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$NEXTAUTH_URL" ]; then
|
||||||
|
echo "❌ NEXTAUTH_URL is required but not set"
|
||||||
|
echo "💡 Set to your application URL, e.g., https://your-domain.com"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check database state and determine what actions are needed
|
||||||
|
echo "📊 Analyzing database state..."
|
||||||
|
DB_CHECK_EXIT=0
|
||||||
|
node dist/check-database.js || DB_CHECK_EXIT=$?
|
||||||
|
|
||||||
|
case $DB_CHECK_EXIT in
|
||||||
|
0)
|
||||||
|
echo "✅ Database is ready - no action needed"
|
||||||
|
;;
|
||||||
|
1)
|
||||||
|
echo "🔧 Database needs migration..."
|
||||||
|
npm run db:push
|
||||||
|
echo "✅ Migration completed"
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
echo "🌱 Database needs seeding..."
|
||||||
|
echo " Admin Email: $ADMIN_EMAIL"
|
||||||
|
echo " Admin Password: [HIDDEN]"
|
||||||
|
node dist/setup-database.js --essential-only
|
||||||
|
echo "✅ Seeding completed"
|
||||||
|
echo "💡 You can now login with: $ADMIN_EMAIL / $ADMIN_PASSWORD"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
echo "🔄 Database needs migration and seeding..."
|
||||||
|
npm run db:push
|
||||||
|
echo "✅ Migration completed"
|
||||||
|
echo "🌱 Seeding database..."
|
||||||
|
echo " Admin Email: $ADMIN_EMAIL"
|
||||||
|
echo " Admin Password: [HIDDEN]"
|
||||||
|
node dist/setup-database.js --essential-only
|
||||||
|
echo "✅ Database initialization completed"
|
||||||
|
echo "💡 You can now login with: $ADMIN_EMAIL / $ADMIN_PASSWORD"
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
echo "❌ Database state check failed - see logs above"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "❌ Unexpected database check result: $DB_CHECK_EXIT"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "🌟 Starting server..."
|
||||||
|
|
||||||
|
# Execute the main command
|
||||||
|
exec "$@"
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "🚀 Starting TT Booking Production..."
|
||||||
|
|
||||||
|
# Create required directories if they don't exist
|
||||||
|
echo "📁 Ensuring directories exist..."
|
||||||
|
mkdir -p /app/data /app/backups /app/logs
|
||||||
|
chmod 755 /app/data /app/backups /app/logs
|
||||||
|
|
||||||
|
# Set defaults for environment variables
|
||||||
|
export DATABASE_URL="${DATABASE_URL:-/app/data/sqlite.db}"
|
||||||
|
export NODE_ENV="${NODE_ENV:-production}"
|
||||||
|
export ADMIN_EMAIL="${ADMIN_EMAIL:-admin@tabletennis.com}"
|
||||||
|
export ADMIN_PASSWORD="${ADMIN_PASSWORD:-admin123}"
|
||||||
|
|
||||||
|
# Validate required environment variables
|
||||||
|
if [ -z "$NEXTAUTH_SECRET" ]; then
|
||||||
|
echo "❌ NEXTAUTH_SECRET is required but not set"
|
||||||
|
echo "💡 Generate one with: openssl rand -base64 32"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$NEXTAUTH_URL" ]; then
|
||||||
|
echo "❌ NEXTAUTH_URL is required but not set"
|
||||||
|
echo "💡 Set to your application URL, e.g., https://your-domain.com"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check database state and determine what actions are needed
|
||||||
|
echo "� Analyzing database state..."
|
||||||
|
DB_CHECK_EXIT=0
|
||||||
|
npx tsx scripts/check-database.ts || DB_CHECK_EXIT=$?
|
||||||
|
|
||||||
|
case $DB_CHECK_EXIT in
|
||||||
|
0)
|
||||||
|
echo "✅ Database is ready - no action needed"
|
||||||
|
;;
|
||||||
|
1)
|
||||||
|
echo "� Database needs migration..."
|
||||||
|
npm run db:push
|
||||||
|
echo "✅ Migration completed"
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
echo "🌱 Database needs seeding..."
|
||||||
|
echo " Admin Email: $ADMIN_EMAIL"
|
||||||
|
echo " Admin Password: [HIDDEN]"
|
||||||
|
npm run db:seed
|
||||||
|
echo "✅ Seeding completed"
|
||||||
|
echo "💡 You can now login with: $ADMIN_EMAIL / $ADMIN_PASSWORD"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
echo "🔄 Database needs migration and seeding..."
|
||||||
|
npm run db:push
|
||||||
|
echo "✅ Migration completed"
|
||||||
|
echo "🌱 Seeding database..."
|
||||||
|
echo " Admin Email: $ADMIN_EMAIL"
|
||||||
|
echo " Admin Password: [HIDDEN]"
|
||||||
|
npm run db:seed
|
||||||
|
echo "✅ Database initialization completed"
|
||||||
|
echo "💡 You can now login with: $ADMIN_EMAIL / $ADMIN_PASSWORD"
|
||||||
|
;;
|
||||||
|
4)
|
||||||
|
echo "❌ Database state check failed - see logs above"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "❌ Unexpected database check result: $DB_CHECK_EXIT"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo "🌟 Starting server..."
|
||||||
|
|
||||||
|
# Execute the main command
|
||||||
|
exec "$@"
|
||||||
@@ -85,6 +85,11 @@ export const ACTIONS = {
|
|||||||
TIME_SLOT_UPDATE: 'update_time_slot',
|
TIME_SLOT_UPDATE: 'update_time_slot',
|
||||||
TIME_SLOT_DELETE: 'delete_time_slot',
|
TIME_SLOT_DELETE: 'delete_time_slot',
|
||||||
|
|
||||||
|
// Court block actions
|
||||||
|
BLOCK_CREATE: 'create_block',
|
||||||
|
BLOCK_UPDATE: 'update_block',
|
||||||
|
BLOCK_DELETE: 'delete_block',
|
||||||
|
|
||||||
// System actions
|
// System actions
|
||||||
SYSTEM_START: 'system_start',
|
SYSTEM_START: 'system_start',
|
||||||
SYSTEM_ERROR: 'system_error',
|
SYSTEM_ERROR: 'system_error',
|
||||||
@@ -97,5 +102,6 @@ export const ENTITY_TYPES = {
|
|||||||
ANNOUNCEMENT: 'announcement',
|
ANNOUNCEMENT: 'announcement',
|
||||||
SETTINGS: 'settings',
|
SETTINGS: 'settings',
|
||||||
TIME_SLOT: 'time_slot',
|
TIME_SLOT: 'time_slot',
|
||||||
|
COURT_BLOCK: 'court_block',
|
||||||
SYSTEM: 'system',
|
SYSTEM: 'system',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE `court_blocks` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`court_id` text,
|
||||||
|
`date` text NOT NULL,
|
||||||
|
`start_time` text NOT NULL,
|
||||||
|
`end_time` text NOT NULL,
|
||||||
|
`reason` text NOT NULL,
|
||||||
|
`created_by` text NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`court_id`) REFERENCES `courts`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`created_by`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE users ADD `theme_preference` text DEFAULT 'system' NOT NULL;
|
||||||
@@ -0,0 +1,649 @@
|
|||||||
|
{
|
||||||
|
"version": "5",
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"id": "cb371f1e-1bfd-4fa2-be7c-a0375bbc11bc",
|
||||||
|
"prevId": "b6ff7034-4299-4b61-8a16-3b46eae7b4ef",
|
||||||
|
"tables": {
|
||||||
|
"activity_logs": {
|
||||||
|
"name": "activity_logs",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"name": "action",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"entity_type": {
|
||||||
|
"name": "entity_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"entity_id": {
|
||||||
|
"name": "entity_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"details": {
|
||||||
|
"name": "details",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"activity_logs_user_id_users_id_fk": {
|
||||||
|
"name": "activity_logs_user_id_users_id_fk",
|
||||||
|
"tableFrom": "activity_logs",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "set null",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"announcements": {
|
||||||
|
"name": "announcements",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"name": "title",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"name": "content",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"name": "priority",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'medium'"
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"bookings": {
|
||||||
|
"name": "bookings",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"court_id": {
|
||||||
|
"name": "court_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"start_time": {
|
||||||
|
"name": "start_time",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"end_time": {
|
||||||
|
"name": "end_time",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'active'"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"name": "notes",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"bookings_user_id_users_id_fk": {
|
||||||
|
"name": "bookings_user_id_users_id_fk",
|
||||||
|
"tableFrom": "bookings",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"bookings_court_id_courts_id_fk": {
|
||||||
|
"name": "bookings_court_id_courts_id_fk",
|
||||||
|
"tableFrom": "bookings",
|
||||||
|
"tableTo": "courts",
|
||||||
|
"columnsFrom": [
|
||||||
|
"court_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"court_blocks": {
|
||||||
|
"name": "court_blocks",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"court_id": {
|
||||||
|
"name": "court_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"name": "date",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"start_time": {
|
||||||
|
"name": "start_time",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"end_time": {
|
||||||
|
"name": "end_time",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"reason": {
|
||||||
|
"name": "reason",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_by": {
|
||||||
|
"name": "created_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"court_blocks_court_id_courts_id_fk": {
|
||||||
|
"name": "court_blocks_court_id_courts_id_fk",
|
||||||
|
"tableFrom": "court_blocks",
|
||||||
|
"tableTo": "courts",
|
||||||
|
"columnsFrom": [
|
||||||
|
"court_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
},
|
||||||
|
"court_blocks_created_by_users_id_fk": {
|
||||||
|
"name": "court_blocks_created_by_users_id_fk",
|
||||||
|
"tableFrom": "court_blocks",
|
||||||
|
"tableTo": "users",
|
||||||
|
"columnsFrom": [
|
||||||
|
"created_by"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"courts": {
|
||||||
|
"name": "courts",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"name": "metrics",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"metric_type": {
|
||||||
|
"name": "metric_type",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"period": {
|
||||||
|
"name": "period",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"name": "settings",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"name": "key",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"settings_key_unique": {
|
||||||
|
"name": "settings_key_unique",
|
||||||
|
"columns": [
|
||||||
|
"key"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"time_slots": {
|
||||||
|
"name": "time_slots",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"day_of_week": {
|
||||||
|
"name": "day_of_week",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"start_time": {
|
||||||
|
"name": "start_time",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"end_time": {
|
||||||
|
"name": "end_time",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"is_active": {
|
||||||
|
"name": "is_active",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
},
|
||||||
|
"users": {
|
||||||
|
"name": "users",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"surname": {
|
||||||
|
"name": "surname",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'user'"
|
||||||
|
},
|
||||||
|
"theme_preference": {
|
||||||
|
"name": "theme_preference",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false,
|
||||||
|
"default": "'system'"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"autoincrement": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
],
|
||||||
|
"isUnique": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"_meta": {
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {},
|
||||||
|
"columns": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,13 @@
|
|||||||
"when": 1758824962110,
|
"when": 1758824962110,
|
||||||
"tag": "0001_slimy_starjammers",
|
"tag": "0001_slimy_starjammers",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "5",
|
||||||
|
"when": 1766916904651,
|
||||||
|
"tag": "0002_thick_makkari",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -104,6 +104,20 @@ export const metrics = sqliteTable('metrics', {
|
|||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Court blocks table for admin-managed closures (tournaments, maintenance, etc.)
|
||||||
|
export const courtBlocks = sqliteTable('court_blocks', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
courtId: text('court_id').references(() => courts.id, { onDelete: 'cascade' }), // NULL means all courts
|
||||||
|
date: text('date').notNull(), // Format: "YYYY-MM-DD"
|
||||||
|
startTime: text('start_time').notNull(), // Format: "HH:MM"
|
||||||
|
endTime: text('end_time').notNull(), // Format: "HH:MM"
|
||||||
|
reason: text('reason').notNull(), // e.g., "Tournament", "AGM", "Maintenance"
|
||||||
|
createdBy: text('created_by')
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
// Zod schemas for validation
|
// Zod schemas for validation
|
||||||
export const insertUserSchema = createInsertSchema(users);
|
export const insertUserSchema = createInsertSchema(users);
|
||||||
export const selectUserSchema = createSelectSchema(users);
|
export const selectUserSchema = createSelectSchema(users);
|
||||||
@@ -121,6 +135,8 @@ export const insertActivityLogSchema = createInsertSchema(activityLogs);
|
|||||||
export const selectActivityLogSchema = createSelectSchema(activityLogs);
|
export const selectActivityLogSchema = createSelectSchema(activityLogs);
|
||||||
export const insertMetricSchema = createInsertSchema(metrics);
|
export const insertMetricSchema = createInsertSchema(metrics);
|
||||||
export const selectMetricSchema = createSelectSchema(metrics);
|
export const selectMetricSchema = createSelectSchema(metrics);
|
||||||
|
export const insertCourtBlockSchema = createInsertSchema(courtBlocks);
|
||||||
|
export const selectCourtBlockSchema = createSelectSchema(courtBlocks);
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type User = typeof users.$inferSelect;
|
export type User = typeof users.$inferSelect;
|
||||||
@@ -139,3 +155,5 @@ export type ActivityLog = typeof activityLogs.$inferSelect;
|
|||||||
export type NewActivityLog = typeof activityLogs.$inferInsert;
|
export type NewActivityLog = typeof activityLogs.$inferInsert;
|
||||||
export type Metric = typeof metrics.$inferSelect;
|
export type Metric = typeof metrics.$inferSelect;
|
||||||
export type NewMetric = typeof metrics.$inferInsert;
|
export type NewMetric = typeof metrics.$inferInsert;
|
||||||
|
export type CourtBlock = typeof courtBlocks.$inferSelect;
|
||||||
|
export type NewCourtBlock = typeof courtBlocks.$inferInsert;
|
||||||
|
|||||||
+2
-2
@@ -49,7 +49,7 @@ export async function createSession(payload: Omit<SessionPayload, 'expiresAt'>)
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
cookieStore.set('session', session, {
|
cookieStore.set('session', session, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production' && process.env.NEXTAUTH_URL?.startsWith('https'),
|
||||||
expires: expiresAt,
|
expires: expiresAt,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -70,7 +70,7 @@ export async function updateSession() {
|
|||||||
|
|
||||||
cookieStore.set('session', newSession, {
|
cookieStore.set('session', newSession, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production' && process.env.NEXTAUTH_URL?.startsWith('https'),
|
||||||
expires: expires,
|
expires: expires,
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
-78
@@ -1,78 +0,0 @@
|
|||||||
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
+54
-177
File diff suppressed because it is too large
Load Diff
+7
-5
@@ -8,13 +8,15 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"db:push": "drizzle-kit push:sqlite",
|
"db:push": "drizzle-kit push:sqlite",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:generate": "drizzle-kit generate:sqlite",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
|
"db:init": "mkdir -p data && npm run db:push",
|
||||||
"db:setup": "tsx scripts/setup-database.ts",
|
"db:setup": "tsx scripts/setup-database.ts",
|
||||||
"db:reset": "tsx scripts/reset-db.ts",
|
"db:reset": "tsx scripts/reset-db.ts",
|
||||||
"db:reset-confirm": "tsx scripts/reset-db.ts --confirm",
|
"db:reset-confirm": "tsx scripts/reset-db.ts --confirm",
|
||||||
"db:seed": "tsx scripts/setup-database.ts --essential-only",
|
"db:seed": "tsx scripts/setup-database.ts --essential-only",
|
||||||
"postinstall": "npm run db:push"
|
"db:check": "tsx scripts/check-database.ts",
|
||||||
|
"postinstall": "npm run db:init"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.2",
|
"@hookform/resolvers": "^5.2.2",
|
||||||
@@ -40,12 +42,13 @@
|
|||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
|
"drizzle-kit": "^0.20.6",
|
||||||
"drizzle-orm": "^0.29.1",
|
"drizzle-orm": "^0.29.1",
|
||||||
"drizzle-zod": "^0.5.1",
|
"drizzle-zod": "^0.5.1",
|
||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.544.0",
|
"lucide-react": "^0.544.0",
|
||||||
"next": "^15.5.3",
|
"next": "^15.5.7",
|
||||||
"next-themes": "^0.2.1",
|
"next-themes": "^0.2.1",
|
||||||
"nodemailer": "^6.9.7",
|
"nodemailer": "^6.9.7",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
@@ -54,6 +57,7 @@
|
|||||||
"react-hook-form": "^7.62.0",
|
"react-hook-form": "^7.62.0",
|
||||||
"tailwind-merge": "^2.1.0",
|
"tailwind-merge": "^2.1.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -65,12 +69,10 @@
|
|||||||
"@types/react": "^18",
|
"@types/react": "^18",
|
||||||
"@types/react-dom": "^18",
|
"@types/react-dom": "^18",
|
||||||
"autoprefixer": "^10.0.1",
|
"autoprefixer": "^10.0.1",
|
||||||
"drizzle-kit": "^0.20.6",
|
|
||||||
"eslint": "^8",
|
"eslint": "^8",
|
||||||
"eslint-config-next": "^15.5.3",
|
"eslint-config-next": "^15.5.3",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"tailwindcss": "^3.3.0",
|
"tailwindcss": "^3.3.0",
|
||||||
"tsx": "^4.20.5",
|
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||||
|
import * as schema from '../lib/db/schema';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { existsSync } from 'fs';
|
||||||
|
|
||||||
|
interface InitResult {
|
||||||
|
needsMigration: boolean;
|
||||||
|
needsSeeding: boolean;
|
||||||
|
hasData: boolean;
|
||||||
|
adminExists: boolean;
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkDatabaseState(): Promise<InitResult> {
|
||||||
|
const dbPath = process.env.DATABASE_URL || './data/sqlite.db';
|
||||||
|
const result: InitResult = {
|
||||||
|
needsMigration: false,
|
||||||
|
needsSeeding: false,
|
||||||
|
hasData: false,
|
||||||
|
adminExists: false,
|
||||||
|
summary: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if database file exists
|
||||||
|
if (!existsSync(dbPath)) {
|
||||||
|
result.needsMigration = true;
|
||||||
|
result.needsSeeding = true;
|
||||||
|
result.summary = 'Database file does not exist - full initialization needed';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sqlite: Database.Database | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
sqlite = new Database(dbPath);
|
||||||
|
const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
|
// Check if core tables exist by trying to query them
|
||||||
|
try {
|
||||||
|
const userCount = db.select().from(schema.users).limit(1).all();
|
||||||
|
const courtCount = db.select().from(schema.courts).limit(1).all();
|
||||||
|
const settingsCount = db.select().from(schema.settings).limit(1).all();
|
||||||
|
|
||||||
|
// Database has tables and some basic structure
|
||||||
|
result.hasData = userCount.length > 0 || courtCount.length > 0 || settingsCount.length > 0;
|
||||||
|
|
||||||
|
// Check for admin users specifically
|
||||||
|
const adminUsers = db
|
||||||
|
.select()
|
||||||
|
.from(schema.users)
|
||||||
|
.where(eq(schema.users.role, 'admin'))
|
||||||
|
.limit(1)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
result.adminExists = adminUsers.length > 0;
|
||||||
|
|
||||||
|
// Determine what's needed
|
||||||
|
if (!result.hasData) {
|
||||||
|
result.needsSeeding = true;
|
||||||
|
result.summary = 'Database exists with empty tables - seeding needed';
|
||||||
|
} else if (!result.adminExists) {
|
||||||
|
result.needsSeeding = true;
|
||||||
|
result.summary = 'Database has data but no admin users found - admin creation needed';
|
||||||
|
} else {
|
||||||
|
result.summary = `Database ready - found ${userCount.length ? 'users' : 'no users'}, ${courtCount.length ? 'courts' : 'no courts'}, admin users present`;
|
||||||
|
}
|
||||||
|
} catch (tableError) {
|
||||||
|
// Tables don't exist or schema is outdated
|
||||||
|
result.needsMigration = true;
|
||||||
|
result.needsSeeding = true;
|
||||||
|
result.summary = 'Database exists but tables missing/outdated - migration and seeding needed';
|
||||||
|
}
|
||||||
|
} catch (dbError) {
|
||||||
|
// Database file exists but is corrupted or inaccessible
|
||||||
|
result.needsMigration = true;
|
||||||
|
result.needsSeeding = true;
|
||||||
|
result.summary = `Database file exists but inaccessible: ${(dbError as Error).message}`;
|
||||||
|
} finally {
|
||||||
|
if (sqlite) {
|
||||||
|
try {
|
||||||
|
sqlite.close();
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore close errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
console.log('🔍 Checking database state...');
|
||||||
|
const state = await checkDatabaseState();
|
||||||
|
|
||||||
|
console.log(`📊 ${state.summary}`);
|
||||||
|
console.log(` Migration needed: ${state.needsMigration ? '✅' : '❌'}`);
|
||||||
|
console.log(` Seeding needed: ${state.needsSeeding ? '✅' : '❌'}`);
|
||||||
|
console.log(` Has existing data: ${state.hasData ? '✅' : '❌'}`);
|
||||||
|
console.log(` Admin user exists: ${state.adminExists ? '✅' : '❌'}`);
|
||||||
|
|
||||||
|
// Output structured result for shell consumption
|
||||||
|
process.env.DB_NEEDS_MIGRATION = state.needsMigration.toString();
|
||||||
|
process.env.DB_NEEDS_SEEDING = state.needsSeeding.toString();
|
||||||
|
process.env.DB_HAS_DATA = state.hasData.toString();
|
||||||
|
process.env.DB_ADMIN_EXISTS = state.adminExists.toString();
|
||||||
|
|
||||||
|
// Exit codes for shell scripting
|
||||||
|
// 0 = ready, 1 = needs migration, 2 = needs seeding, 3 = needs both
|
||||||
|
if (state.needsMigration && state.needsSeeding) {
|
||||||
|
process.exit(3);
|
||||||
|
} else if (state.needsMigration) {
|
||||||
|
process.exit(1);
|
||||||
|
} else if (state.needsSeeding) {
|
||||||
|
process.exit(2);
|
||||||
|
} else {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Database state check failed:', error);
|
||||||
|
process.exit(4); // Error state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
main();
|
||||||
|
}
|
||||||
|
|
||||||
|
export { checkDatabaseState, type InitResult };
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# Cleanup old seed scripts and organize database utilities
|
|
||||||
# This script consolidates old individual seed scripts into the new unified system
|
|
||||||
|
|
||||||
echo "🧹 Cleaning up old database scripts..."
|
|
||||||
|
|
||||||
# Create a backup directory for old scripts
|
|
||||||
mkdir -p scripts/old-seeds
|
|
||||||
|
|
||||||
# Move old individual seed scripts to backup
|
|
||||||
echo "📦 Moving old seed scripts to scripts/old-seeds/..."
|
|
||||||
|
|
||||||
if [ -f "scripts/seed-data.ts" ]; then
|
|
||||||
mv scripts/seed-data.ts scripts/old-seeds/
|
|
||||||
echo " ✓ Moved seed-data.ts"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "scripts/seed-announcements.ts" ]; then
|
|
||||||
mv scripts/seed-announcements.ts scripts/old-seeds/
|
|
||||||
echo " ✓ Moved seed-announcements.ts"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "scripts/seed-time-slots.ts" ]; then
|
|
||||||
mv scripts/seed-time-slots.ts scripts/old-seeds/
|
|
||||||
echo " ✓ Moved seed-time-slots.ts"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -f "scripts/init-admin-data.ts" ]; then
|
|
||||||
mv scripts/init-admin-data.ts scripts/old-seeds/
|
|
||||||
echo " ✓ Moved init-admin-data.ts"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Remove old reset script if it exists
|
|
||||||
if [ -f "scripts/reset-database.ts" ]; then
|
|
||||||
mv scripts/reset-database.ts scripts/old-seeds/
|
|
||||||
echo " ✓ Moved old reset-database.ts"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create a README in the old-seeds directory
|
|
||||||
cat > scripts/old-seeds/README.md << 'EOF'
|
|
||||||
# Old Seed Scripts (Archived)
|
|
||||||
|
|
||||||
These are the original individual seed scripts that have been consolidated into the new unified database setup system.
|
|
||||||
|
|
||||||
## Consolidated Into:
|
|
||||||
|
|
||||||
- **setup-database.ts** - Unified setup script with all functionality
|
|
||||||
- **reset-db.ts** - Improved reset script with safety features
|
|
||||||
|
|
||||||
## Original Scripts:
|
|
||||||
|
|
||||||
- `seed-data.ts` - Sample bookings and activity logs
|
|
||||||
- `seed-announcements.ts` - Test announcements
|
|
||||||
- `seed-time-slots.ts` - Time slot configuration
|
|
||||||
- `init-admin-data.ts` - Admin dashboard initialization
|
|
||||||
- `reset-database.ts` - Original reset script
|
|
||||||
|
|
||||||
## Migration Notes:
|
|
||||||
|
|
||||||
All functionality from these scripts has been intelligently integrated into the new system:
|
|
||||||
|
|
||||||
### New Command Equivalents:
|
|
||||||
|
|
||||||
| Old Script | New Command |
|
|
||||||
|------------|-------------|
|
|
||||||
| `tsx scripts/seed-data.ts` | `npm run db:setup` |
|
|
||||||
| `tsx scripts/seed-announcements.ts` | Integrated into setup |
|
|
||||||
| `tsx scripts/seed-time-slots.ts` | Integrated into setup |
|
|
||||||
| `tsx scripts/init-admin-data.ts` | Integrated into setup |
|
|
||||||
| `tsx scripts/reset-database.ts` | `npm run db:reset-confirm` |
|
|
||||||
|
|
||||||
### Advantages of New System:
|
|
||||||
|
|
||||||
1. **Intelligent Setup** - Automatically handles dependencies and order
|
|
||||||
2. **Flexible Options** - Essential-only or full sample data modes
|
|
||||||
3. **Safety Features** - Reset confirmation and detailed logging
|
|
||||||
4. **Better Documentation** - Comprehensive help and summaries
|
|
||||||
5. **Single Source** - All database setup in one place
|
|
||||||
|
|
||||||
These old scripts are kept for reference but should not be used in new development.
|
|
||||||
EOF
|
|
||||||
|
|
||||||
echo " ✓ Created README in old-seeds directory"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "✅ Cleanup complete!"
|
|
||||||
echo ""
|
|
||||||
echo "📋 Summary of changes:"
|
|
||||||
echo " • Old seed scripts moved to scripts/old-seeds/"
|
|
||||||
echo " • New unified system active:"
|
|
||||||
echo " - setup-database.ts (comprehensive setup)"
|
|
||||||
echo " - reset-db.ts (safe reset with confirmation)"
|
|
||||||
echo ""
|
|
||||||
echo "🚀 New database commands:"
|
|
||||||
echo " npm run db:setup # Full setup with sample data"
|
|
||||||
echo " npm run db:seed # Essential data only"
|
|
||||||
echo " npm run db:reset # Safe reset (shows warning)"
|
|
||||||
echo " npm run db:reset-confirm # Immediate reset"
|
|
||||||
echo ""
|
|
||||||
echo "📖 See DATABASE_SETUP.md for detailed documentation"
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
# Old Seed Scripts (Archived)
|
|
||||||
|
|
||||||
These are the original individual seed scripts that have been consolidated into the new unified database setup system.
|
|
||||||
|
|
||||||
## Consolidated Into:
|
|
||||||
|
|
||||||
- **setup-database.ts** - Unified setup script with all functionality
|
|
||||||
- **reset-db.ts** - Improved reset script with safety features
|
|
||||||
|
|
||||||
## Original Scripts:
|
|
||||||
|
|
||||||
- `seed-data.ts` - Sample bookings and activity logs
|
|
||||||
- `seed-announcements.ts` - Test announcements
|
|
||||||
- `seed-time-slots.ts` - Time slot configuration
|
|
||||||
- `init-admin-data.ts` - Admin dashboard initialization
|
|
||||||
- `reset-database.ts` - Original reset script
|
|
||||||
|
|
||||||
## Migration Notes:
|
|
||||||
|
|
||||||
All functionality from these scripts has been intelligently integrated into the new system:
|
|
||||||
|
|
||||||
### New Command Equivalents:
|
|
||||||
|
|
||||||
| Old Script | New Command |
|
|
||||||
|------------|-------------|
|
|
||||||
| `tsx scripts/seed-data.ts` | `npm run db:setup` |
|
|
||||||
| `tsx scripts/seed-announcements.ts` | Integrated into setup |
|
|
||||||
| `tsx scripts/seed-time-slots.ts` | Integrated into setup |
|
|
||||||
| `tsx scripts/init-admin-data.ts` | Integrated into setup |
|
|
||||||
| `tsx scripts/reset-database.ts` | `npm run db:reset-confirm` |
|
|
||||||
|
|
||||||
### Advantages of New System:
|
|
||||||
|
|
||||||
1. **Intelligent Setup** - Automatically handles dependencies and order
|
|
||||||
2. **Flexible Options** - Essential-only or full sample data modes
|
|
||||||
3. **Safety Features** - Reset confirmation and detailed logging
|
|
||||||
4. **Better Documentation** - Comprehensive help and summaries
|
|
||||||
5. **Single Source** - All database setup in one place
|
|
||||||
|
|
||||||
These old scripts are kept for reference but should not be used in new development.
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { db } from '../lib/db';
|
|
||||||
import { settings, metrics } from '../lib/db/schema';
|
|
||||||
import { randomUUID } from 'crypto';
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
|
|
||||||
async function initializeAdminData() {
|
|
||||||
try {
|
|
||||||
console.log('Initializing admin dashboard data...');
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
// Initialize default settings
|
|
||||||
console.log('Setting up default settings...');
|
|
||||||
|
|
||||||
const defaultSettings = [
|
|
||||||
{
|
|
||||||
id: randomUUID(),
|
|
||||||
key: 'booking_window_days',
|
|
||||||
value: '14',
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: randomUUID(),
|
|
||||||
key: 'max_booking_duration_hours',
|
|
||||||
value: '2',
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: randomUUID(),
|
|
||||||
key: 'max_bookings_per_user_per_hour_per_day',
|
|
||||||
value: '1',
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: randomUUID(),
|
|
||||||
key: 'allow_booking_modifications',
|
|
||||||
value: 'true',
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: randomUUID(),
|
|
||||||
key: 'booking_modification_hours_before',
|
|
||||||
value: '2',
|
|
||||||
updatedAt: now,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Insert settings if they don't exist
|
|
||||||
for (const setting of defaultSettings) {
|
|
||||||
const existingSetting = await db.select().from(settings).where(eq(settings.key, setting.key)).limit(1);
|
|
||||||
|
|
||||||
if (existingSetting.length === 0) {
|
|
||||||
await db.insert(settings).values(setting);
|
|
||||||
console.log(`✓ Setting '${setting.key}' initialized with value '${setting.value}'`);
|
|
||||||
} else {
|
|
||||||
console.log(`- Setting '${setting.key}' already exists`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize current month's metrics
|
|
||||||
console.log('Initializing monthly metrics...');
|
|
||||||
|
|
||||||
const currentMonth = now.toISOString().substring(0, 7); // "2025-09"
|
|
||||||
|
|
||||||
const existingMetric = await db.select().from(metrics).where(eq(metrics.period, currentMonth)).limit(1);
|
|
||||||
|
|
||||||
if (existingMetric.length === 0) {
|
|
||||||
const monthlyMetric = {
|
|
||||||
id: randomUUID(),
|
|
||||||
metricType: 'monthly_bookings',
|
|
||||||
period: currentMonth,
|
|
||||||
value: 0,
|
|
||||||
createdAt: now,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
|
|
||||||
await db.insert(metrics).values(monthlyMetric);
|
|
||||||
console.log(`✓ Monthly metrics initialized for ${currentMonth}`);
|
|
||||||
} else {
|
|
||||||
console.log(`- Monthly metrics for ${currentMonth} already exist`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Admin dashboard data initialization complete!');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error initializing admin data:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
initializeAdminData();
|
|
||||||
@@ -1,263 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { db } from '../lib/db';
|
|
||||||
import { timeSlots } from '../lib/db/schema';
|
|
||||||
|
|
||||||
async function seedTimeSlots() {
|
|
||||||
console.log('🌱 Seeding time slots...');
|
|
||||||
|
|
||||||
// Example time slots for different days
|
|
||||||
const timeSlotData = [
|
|
||||||
// Sunday: 12:00 - 17:00
|
|
||||||
{ dayOfWeek: 0, startTime: '12:00', endTime: '17:00' },
|
|
||||||
|
|
||||||
// Monday: 19:00 - 23:00
|
|
||||||
{ dayOfWeek: 1, startTime: '19:00', endTime: '23:00' },
|
|
||||||
|
|
||||||
// Tuesday: 19:00 - 23:00
|
|
||||||
{ dayOfWeek: 2, startTime: '19:00', endTime: '23:00' },
|
|
||||||
|
|
||||||
// Wednesday: NO SLOTS (facility closed)
|
|
||||||
// { dayOfWeek: 3, startTime: '18:00', endTime: '22:00' },
|
|
||||||
|
|
||||||
// Thursday: NO SLOTS (facility closed)
|
|
||||||
// { dayOfWeek: 4, startTime: '19:00', endTime: '23:00' },
|
|
||||||
|
|
||||||
// Friday: 18:00 - 22:00
|
|
||||||
{ dayOfWeek: 5, startTime: '18:00', endTime: '22:00' },
|
|
||||||
|
|
||||||
// Saturday: 10:00 - 18:00
|
|
||||||
{ dayOfWeek: 6, startTime: '10:00', endTime: '18:00' },
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const slot of timeSlotData) {
|
|
||||||
await db.insert(timeSlots).values({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
dayOfWeek: slot.dayOfWeek,
|
|
||||||
startTime: slot.startTime,
|
|
||||||
endTime: slot.endTime,
|
|
||||||
isActive: true,
|
|
||||||
createdAt: new Date(),
|
|
||||||
updatedAt: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Time slots seeding completed');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run the seeding function
|
|
||||||
seedTimeSlots()
|
|
||||||
.then(() => {
|
|
||||||
console.log('Time slots seeding process completed');
|
|
||||||
process.exit(0);
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('Error during time slots seeding:', error);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
+2
-1
@@ -3,7 +3,8 @@ import { drizzle } from 'drizzle-orm/better-sqlite3';
|
|||||||
import * as schema from '../lib/db/schema';
|
import * as schema from '../lib/db/schema';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
|
|
||||||
const sqlite = new Database('./sqlite.db');
|
const dbPath = process.env.DATABASE_URL || './data/sqlite.db';
|
||||||
|
const sqlite = new Database(dbPath);
|
||||||
const db = drizzle(sqlite, { schema });
|
const db = drizzle(sqlite, { schema });
|
||||||
|
|
||||||
interface ResetOptions {
|
interface ResetOptions {
|
||||||
|
|||||||
@@ -598,7 +598,7 @@ async function printDatabaseSummary() {
|
|||||||
console.log(`\n⚙️ Settings: ${settings.length} configured`);
|
console.log(`\n⚙️ Settings: ${settings.length} configured`);
|
||||||
|
|
||||||
console.log('\n💡 Login Credentials:');
|
console.log('\n💡 Login Credentials:');
|
||||||
console.log(' Admin: admin@tabletennis.com / admin123');
|
console.log(` Admin: ${process.env.ADMIN_EMAIL || 'admin@tabletennis.com'} / ${process.env.ADMIN_PASSWORD || 'admin123'}`);
|
||||||
console.log(' User: user@tabletennis.com / user123');
|
console.log(' User: user@tabletennis.com / user123');
|
||||||
|
|
||||||
console.log('\n🚀 Ready to start! Run: npm run dev');
|
console.log('\n🚀 Ready to start! Run: npm run dev');
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user