Compare commits

...

9 Commits

Author SHA1 Message Date
mikicv f45a6f46a5 Merge pull request 'feat: implement admin blocks management feature' (#2) from feat/booking-blocks/court-blocking into main
Reviewed-on: #2
2025-12-29 17:09:00 +00:00
mikicvi 40c56770a2 feat: implement admin blocks management feature
- Added AdminBlocksManagement component for managing court blocks.
- Implemented functionality to create, edit, and delete blocks.
- Integrated fetching of courts and blocks from the API.
- Added validation for block creation and editing forms.
- Enhanced UI with responsive design for mobile and desktop views.
- Created database migration for court_blocks table and updated users table with theme_preference.
2025-12-29 17:04:16 +00:00
mikicv 54240a2cfd Merge pull request 'Deps: update dependencies and script' (#1) from deps/security/upgrade-modules into main
Reviewed-on: #1
2025-12-29 17:02:32 +00:00
mikicvi ab1ac4427a chore: update database command and next.js version
- Changed the database migration command from "db:migrate" to "db:generate" for SQLite.
- Updated the Next.js version from 15.5.3 to 15.5.7 in package.json.
2025-12-29 16:59:57 +00:00
mikicvi 69b456f3f8 Update license year, enhance user management with last booking date, and improve admin dashboard navigation 2025-10-06 11:18:55 +01:00
mikicvi 7fdd7285a4 restructure docs and config, license, readme redo 2025-09-28 21:49:56 +01:00
mikicvi 1911aa9211 Enhance admin dashboard and header with responsive layouts and additional navigation buttons, icon 2025-09-28 21:31:24 +01:00
mikicv 43c0cf1359 docker image to alpine, reduce size, compile scripts, entrypoint for alpine 2025-09-28 20:47:23 +01:00
mikicv d4aa460f91 fully working when deployed 2025-09-28 18:47:31 +01:00
35 changed files with 3153 additions and 743 deletions
-33
View File
@@ -1,33 +0,0 @@
# Environment Configuration Template
# Copy this to .env.production and fill in your values
# === REQUIRED VARIABLES ===
# Database URL (host path - gets mounted into container)
DATABASE_URL=./data/sqlite.db
# NextAuth.js Configuration (REQUIRED)
NEXTAUTH_URL=https://your-domain.com
NEXTAUTH_SECRET=your-long-random-secret-here-generate-with-openssl-rand-base64-32
# Admin User (CHANGE THESE!)
ADMIN_EMAIL=admin@your-domain.com
ADMIN_PASSWORD=your-secure-admin-password
# === OPTIONAL VARIABLES ===
# Application Environment
NODE_ENV=production
PORT=3000
# Email Configuration (for notifications - optional)
EMAIL_USER=your-email@gmail.com
EMAIL_PASSWORD=your-gmail-app-password
# Rate Limiting (defaults provided)
RATE_LIMIT_MAX=100
RATE_LIMIT_WINDOW=900000
# Logging Level
LOG_LEVEL=info
# Local Development Override (for HTTP testing)
DISABLE_SECURE_COOKIES=false
+20 -2
View File
@@ -17,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
@@ -28,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
@@ -48,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
+32 -32
View File
@@ -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,52 +23,51 @@ 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..."
# Ensure database directory has proper permissions
if [ -d "/app/data" ]; then
echo "📁 Setting database permissions..."
chmod -R 755 /app/data /app/backups /app/logs 2>/dev/null || true
if [ -f "/app/data/sqlite.db" ]; then
chmod 644 /app/data/sqlite.db 2>/dev/null || true
fi
fi
echo "🌟 Starting server..."
exec node server.js
EOF
RUN chmod +x /app/start.sh
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 ENV PORT=3000
@@ -78,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
View File
@@ -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.
```
+77 -152
View File
@@ -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 ```bash
# Clone and install
git clone <repository-url> git clone <repository-url>
cd tt-booking cd tt-booking
```
2. **Install dependencies**
```bash
npm install npm install
```
3. **Set up environment variables** # Configure environment
cp .env.sample .env.local
# Edit .env.local with your settings
```bash # Setup database
cp .env.example .env.local npm run db:setup
```
Edit `.env.local` with your configuration: # Start development
```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 npm run dev
``` ```
6. **Access the application** **Access**: http://localhost:3000 (Admin: `/admin`)
- User interface: http://localhost:3000
- Admin panel: http://localhost:3000/admin
## Configuration ## 🔧 Configuration
### Gmail Setup Create `.env.local` with:
1. Enable 2-factor authentication on your Gmail account ```env
2. Generate an App Password: Google Account > Security > App passwords NEXTAUTH_SECRET=your-very-long-random-secret-key
3. Use the App Password as `EMAIL_PASSWORD` in your environment variables NEXTAUTH_URL=http://localhost:3000
ADMIN_EMAIL=admin@your-domain.com
ADMIN_PASSWORD=your-secure-password
### Default Settings # Optional - Email notifications
EMAIL_USER=your-email@gmail.com
- **Courts**: 2 courts (configurable via admin panel) EMAIL_PASSWORD=your-gmail-app-password
- **Monday/Tuesday**: 19:00-23:00 (configurable)
- **Sunday**: 12:00-17:00 (configurable)
- **Booking window**: 7 days from current date
## Docker Deployment
### Development
```bash
docker-compose up -d
``` ```
### Production ## 🗄️ Database Commands
1. **Update environment variables** in `docker-compose.yml`
2. **Configure SSL certificates** in the `ssl` directory
3. **Update domain** in `nginx.conf`
4. **Deploy**:
```bash ```bash
docker-compose -f docker-compose.yml up -d 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
``` ```
## Project Structure ## 🐳 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.
+128
View File
@@ -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 });
}
}
+176
View File
@@ -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 });
}
}
+7 -3
View File
@@ -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) {
+51
View File
@@ -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 });
}
}
+53 -6
View File
@@ -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,7 +152,8 @@ 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
if (!isAdmin) {
const isTimeSlotValid = availableTimeSlots.some((slot) => { const isTimeSlotValid = availableTimeSlots.some((slot) => {
const slotStartHour = parseInt(slot.startTime.split(':')[0]); const slotStartHour = parseInt(slot.startTime.split(':')[0]);
const slotEndHour = parseInt(slot.endTime.split(':')[0]); const slotEndHour = parseInt(slot.endTime.split(':')[0]);
@@ -128,15 +171,18 @@ export async function POST(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
}
// Check booking restrictions per user per hour per day // Check booking restrictions per user per hour per day
// Admins bypass this restriction
if (!isAdmin) {
const maxBookingsSetting = await db const maxBookingsSetting = await db
.select() .select()
.from(settings) .from(settings)
.where(eq(settings.key, 'max_bookings_per_user_per_hour_per_day')) .where(eq(settings.key, 'max_bookings_per_user_per_hour_per_day'))
.limit(1); .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
@@ -159,6 +205,7 @@ export async function POST(request: NextRequest) {
{ status: 400 } { status: 400 }
); );
} }
}
// Check if slot is already booked // Check if slot is already booked
const existingBooking = await db const existingBooking = await db
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+38
View File
@@ -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!');
+753
View File
@@ -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>
);
}
+33 -7
View File
@@ -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,6 +237,20 @@ export function AdminCourtManagement() {
} }
return ( return (
<div className='space-y-6'>
<Tabs defaultValue='courts' className='w-full'>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='courts' className='flex items-center gap-2'>
<MapPin className='h-4 w-4' />
Courts
</TabsTrigger>
<TabsTrigger value='closures' className='flex items-center gap-2'>
<Ban className='h-4 w-4' />
Closures
</TabsTrigger>
</TabsList>
<TabsContent value='courts'>
<Card> <Card>
<CardHeader className='flex flex-row items-center justify-between'> <CardHeader className='flex flex-row items-center justify-between'>
<CardTitle className='flex items-center gap-2'> <CardTitle className='flex items-center gap-2'>
@@ -256,7 +271,9 @@ export function AdminCourtManagement() {
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{editingCourt ? 'Edit Court' : 'Create New Court'}</DialogTitle> <DialogTitle>
{editingCourt ? 'Edit Court' : 'Create New Court'}
</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className='space-y-4'> <form onSubmit={handleSubmit} className='space-y-4'>
<div> <div>
@@ -319,7 +336,8 @@ export function AdminCourtManagement() {
<div> <div>
<h3 className='font-medium'>{court.name}</h3> <h3 className='font-medium'>{court.name}</h3>
<p className='text-sm text-gray-500'> <p className='text-sm text-gray-500'>
Created {new Date(court.createdAt).toLocaleDateString('en-IE')} Created{' '}
{new Date(court.createdAt).toLocaleDateString('en-IE')}
</p> </p>
</div> </div>
</div> </div>
@@ -366,8 +384,9 @@ export function AdminCourtManagement() {
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle> <AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Are you sure you want to delete {courtToDelete ? `"${courtToDelete.name}"` : 'this court'}? Are you sure you want to delete{' '}
This action cannot be undone. {courtToDelete ? `"${courtToDelete.name}"` : 'this court'}? This action cannot
be undone.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
@@ -382,5 +401,12 @@ export function AdminCourtManagement() {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</Card> </Card>
</TabsContent>
<TabsContent value='closures'>
<AdminBlocksManagement />
</TabsContent>
</Tabs>
</div>
); );
} }
+95 -1
View File
@@ -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,6 +388,8 @@ export function AdminUserManagement() {
<CardTitle>All Users ({filteredUsers.length})</CardTitle> <CardTitle>All Users ({filteredUsers.length})</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{/* Desktop Table */}
<div className='hidden md:block'>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -394,6 +397,7 @@ export function AdminUserManagement() {
<TableHead>Email</TableHead> <TableHead>Email</TableHead>
<TableHead>Role</TableHead> <TableHead>Role</TableHead>
<TableHead>Created</TableHead> <TableHead>Created</TableHead>
<TableHead>Last Played</TableHead>
<TableHead>Actions</TableHead> <TableHead>Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -420,9 +424,26 @@ export function AdminUserManagement() {
{new Date(user.createdAt).toLocaleDateString('en-IE')} {new Date(user.createdAt).toLocaleDateString('en-IE')}
</div> </div>
</TableCell> </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> <TableCell>
<div className='flex gap-2'> <div className='flex gap-2'>
<Button variant='outline' size='sm' onClick={() => openEditDialog(user)}> <Button
variant='outline'
size='sm'
onClick={() => openEditDialog(user)}
>
<Edit className='h-4 w-4' /> <Edit className='h-4 w-4' />
</Button> </Button>
<Button <Button
@@ -439,6 +460,79 @@ export function AdminUserManagement() {
))} ))}
</TableBody> </TableBody>
</Table> </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>
<Badge
variant={user.role === 'admin' ? 'default' : 'secondary'}
className='text-xs'
>
{user.role}
</Badge>
</div>
<div className='grid grid-cols-2 gap-3 text-xs'>
<div>
<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>
<span className='text-muted-foreground block'>Last Played</span>
{user.lastBookingDate ? (
<span className='flex items-center gap-1 mt-1'>
<Calendar className='h-3 w-3 text-green-600' />
{new Date(user.lastBookingDate).toLocaleDateString('en-IE')}
</span>
) : (
<span className='flex items-center gap-1 mt-1 text-gray-500'>
<Calendar className='h-3 w-3' />
Never played
</span>
)}
</div>
</div>
<div className='flex gap-2 pt-2'>
<Button
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
+116 -5
View File
@@ -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,7 +636,15 @@ 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='flex items-center gap-2 text-xs text-orange-600 dark:text-orange-400'>
<Ban className='h-3 w-3' />
{slot.blockReason || 'Blocked'}
</div>
)}
{!slot.blocked &&
!slot.available &&
slot.bookedBy && (
<div className='space-y-1'> <div className='space-y-1'>
<div className='flex items-center gap-2 text-xs text-muted-foreground'> <div className='flex items-center gap-2 text-xs text-muted-foreground'>
<Users className='h-3 w-3' /> <Users className='h-3 w-3' />
@@ -596,7 +658,9 @@ export function EnhancedBookingCalendar() {
)} )}
</div> </div>
)} )}
{!slot.available && !slot.bookedBy && ( {!slot.blocked &&
!slot.available &&
!slot.bookedBy && (
<div className='text-xs text-muted-foreground'> <div className='text-xs text-muted-foreground'>
Already booked Already booked
</div> </div>
@@ -604,17 +668,27 @@ export function EnhancedBookingCalendar() {
</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>
+69 -1
View File
@@ -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 */}
+22
View File
@@ -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
+54
View File
@@ -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
+31 -51
View File
@@ -14,84 +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
# Fix database permissions for container
echo "🔒 Fixing database permissions for container..."
if [ -f "data/sqlite.db" ]; then
chmod 666 data/sqlite.db
fi
chmod 777 data backups logs
# 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:3036/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:3036/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 ""
-7
View File
@@ -4,17 +4,10 @@ services:
context: . context: .
dockerfile: Dockerfile.production dockerfile: Dockerfile.production
container_name: lcc-tt-booking container_name: lcc-tt-booking
user: "1000:1000"
ports: ports:
- '3036:3000' - '3036:3000'
env_file: env_file:
- .env.production - .env.production
- '3036:3000'
env_file:
- .env.production
environment:
# Container-specific override
- PORT=3000
volumes: volumes:
- ./data:/app/data - ./data:/app/data
- ./backups:/app/backups - ./backups:/app/backups
+76
View File
@@ -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 "$@"
+76
View File
@@ -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 "$@"
+6
View File
@@ -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;
+14
View File
@@ -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;
+649
View File
@@ -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": {}
}
}
+7
View File
@@ -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
} }
] ]
} }
+18
View File
@@ -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;
+54 -177
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -8,13 +8,14 @@
"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: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",
"db:check": "tsx scripts/check-database.ts",
"postinstall": "npm run db:init" "postinstall": "npm run db:init"
}, },
"dependencies": { "dependencies": {
@@ -41,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",
@@ -55,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": {
@@ -66,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"
} }
} }
+130
View File
@@ -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 };