Comprehensive task-by-task plan covering: - Phase 1: Project foundation (Turborepo, Next.js, Prisma, Auth) - Phase 2: UI components (shadcn/ui, layouts) - Phase 3: Bookings module (core functionality) - Phase 4: POS, Tournaments, Memberships - Phase 5: Dashboard and Reports - Phase 6: Mobile app (React Native/Expo) Each task includes exact file paths, complete code, and commit messages. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
70 KiB
Padel Pro - Plan de Implementación
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Construir sistema completo de gestión de clubes de pádel multi-sede con reservas, POS, torneos y membresías.
Architecture: Monorepo con Turborepo. Next.js 14 (App Router) para web admin + API. React Native con Expo para app de clientes. PostgreSQL con Prisma ORM. Autenticación con NextAuth.js.
Tech Stack: TypeScript, Next.js 14, React Native/Expo, Tailwind CSS, shadcn/ui, Prisma, PostgreSQL, Zod, React Query
Fase 1: Fundación del Proyecto
Task 1: Inicializar Monorepo con Turborepo
Files:
- Create:
package.json - Create:
turbo.json - Create:
pnpm-workspace.yaml - Create:
.gitignore - Create:
.nvmrc
Step 1: Crear estructura base del monorepo
cd /root/Padel
pnpm init
Step 2: Configurar pnpm workspace
Create pnpm-workspace.yaml:
packages:
- "apps/*"
- "packages/*"
Step 3: Configurar Turborepo
Create turbo.json:
{
"$schema": "https://turbo.build/schema.json",
"globalDependencies": ["**/.env.*local"],
"pipeline": {
"build": {
"dependsOn": ["^build"],
"outputs": [".next/**", "!.next/cache/**", "dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": {},
"type-check": {
"dependsOn": ["^build"]
},
"db:generate": {
"cache": false
},
"db:push": {
"cache": false
}
}
}
Step 4: Actualizar package.json raíz
{
"name": "padel-pro",
"private": true,
"scripts": {
"dev": "turbo dev",
"build": "turbo build",
"lint": "turbo lint",
"type-check": "turbo type-check",
"db:generate": "turbo db:generate",
"db:push": "turbo db:push"
},
"devDependencies": {
"turbo": "^2.0.0"
},
"packageManager": "pnpm@8.15.0"
}
Step 5: Crear .gitignore
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
.next/
dist/
.turbo/
# Environment
.env
.env.local
.env.*.local
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
# Testing
coverage/
# Prisma
prisma/*.db
prisma/*.db-journal
Step 6: Crear .nvmrc
20.11.0
Step 7: Crear directorios base
mkdir -p apps/web apps/mobile packages/shared
Step 8: Commit
git add -A
git commit -m "chore: initialize monorepo with Turborepo and pnpm"
Task 2: Configurar App Web (Next.js 14)
Files:
- Create:
apps/web/package.json - Create:
apps/web/next.config.js - Create:
apps/web/tsconfig.json - Create:
apps/web/tailwind.config.ts - Create:
apps/web/postcss.config.js - Create:
apps/web/app/layout.tsx - Create:
apps/web/app/page.tsx - Create:
apps/web/app/globals.css
Step 1: Crear package.json para web
Create apps/web/package.json:
{
"name": "@padel-pro/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --port 3000",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"next": "14.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@padel-pro/shared": "workspace:*"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.3.3"
}
}
Step 2: Crear next.config.js
Create apps/web/next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ["@padel-pro/shared"],
images: {
remotePatterns: [
{
protocol: "https",
hostname: "res.cloudinary.com",
},
],
},
};
module.exports = nextConfig;
Step 3: Crear tsconfig.json
Create apps/web/tsconfig.json:
{
"compilerOptions": {
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Step 4: Crear tailwind.config.ts
Create apps/web/tailwind.config.ts:
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: ["class"],
content: [
"./pages/**/*.{ts,tsx}",
"./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}",
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: "#1E3A5F",
50: "#E8EDF3",
100: "#D1DBE7",
200: "#A3B7CF",
300: "#7593B7",
400: "#476F9F",
500: "#1E3A5F",
600: "#182E4C",
700: "#122339",
800: "#0C1726",
900: "#060C13",
},
accent: {
DEFAULT: "#22C55E",
50: "#E8FAF0",
100: "#D1F5E1",
200: "#A3EBC3",
300: "#75E1A5",
400: "#47D787",
500: "#22C55E",
600: "#1B9E4B",
700: "#147638",
800: "#0D4F25",
900: "#072712",
},
},
fontFamily: {
sans: ["Inter", "system-ui", "sans-serif"],
},
},
},
plugins: [],
};
export default config;
Step 5: Crear postcss.config.js
Create apps/web/postcss.config.js:
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Step 6: Crear globals.css
Create apps/web/app/globals.css:
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 248 250 252;
--foreground: 30 41 59;
--primary: 30 58 95;
--primary-foreground: 255 255 255;
--accent: 34 197 94;
--accent-foreground: 255 255 255;
--muted: 241 245 249;
--muted-foreground: 100 116 139;
--border: 226 232 240;
--ring: 30 58 95;
--radius: 0.5rem;
}
.dark {
--background: 15 23 42;
--foreground: 248 250 252;
--primary: 96 165 250;
--primary-foreground: 15 23 42;
--muted: 30 41 59;
--muted-foreground: 148 163 184;
--border: 51 65 85;
}
* {
@apply border-slate-200;
}
body {
@apply bg-slate-50 text-slate-900 antialiased;
}
}
Step 7: Crear layout.tsx
Create apps/web/app/layout.tsx:
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Padel Pro - Sistema de Gestión",
description: "Sistema integral de gestión para clubes de pádel",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="es">
<body className={inter.className}>{children}</body>
</html>
);
}
Step 8: Crear page.tsx inicial
Create apps/web/app/page.tsx:
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24">
<div className="text-center">
<h1 className="text-4xl font-bold text-primary mb-4">
Padel Pro
</h1>
<p className="text-xl text-slate-600">
Sistema de Gestión para Clubes de Pádel
</p>
<div className="mt-8 flex gap-4 justify-center">
<div className="px-6 py-3 bg-primary text-white rounded-lg">
Dashboard
</div>
<div className="px-6 py-3 bg-accent text-white rounded-lg">
Reservas
</div>
</div>
</div>
</main>
);
}
Step 9: Commit
git add -A
git commit -m "feat(web): add Next.js 14 app with Tailwind CSS"
Task 3: Configurar Paquete Shared
Files:
- Create:
packages/shared/package.json - Create:
packages/shared/tsconfig.json - Create:
packages/shared/src/index.ts - Create:
packages/shared/src/types/index.ts - Create:
packages/shared/src/validations/index.ts
Step 1: Crear package.json
Create packages/shared/package.json:
{
"name": "@padel-pro/shared",
"version": "0.1.0",
"private": true,
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"type-check": "tsc --noEmit",
"lint": "eslint src/"
},
"dependencies": {
"zod": "^3.22.4"
},
"devDependencies": {
"typescript": "^5.3.3"
}
}
Step 2: Crear tsconfig.json
Create packages/shared/tsconfig.json:
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Step 3: Crear tipos base
Create packages/shared/src/types/index.ts:
// Enums
export type UserRole = "SUPER_ADMIN" | "SITE_ADMIN" | "RECEPTIONIST";
export type CourtType = "SINGLES" | "DOUBLES" | "MIXED";
export type CourtStatus = "ACTIVE" | "MAINTENANCE" | "INACTIVE";
export type BookingStatus = "PENDING" | "CONFIRMED" | "CANCELLED" | "COMPLETED";
export type PaymentType = "CASH" | "TRANSFER" | "CARD_TERMINAL";
export type MembershipStatus = "ACTIVE" | "EXPIRED" | "CANCELLED";
export type TournamentType = "SINGLE_ELIMINATION" | "DOUBLE_ELIMINATION" | "ROUND_ROBIN" | "LEAGUE";
export type TournamentStatus = "DRAFT" | "OPEN" | "IN_PROGRESS" | "FINISHED" | "CANCELLED";
export type MatchStatus = "PENDING" | "IN_PROGRESS" | "FINISHED";
export type CashRegisterStatus = "OPEN" | "CLOSED";
// Base types
export interface Organization {
id: string;
name: string;
logo?: string;
createdAt: Date;
}
export interface Site {
id: string;
organizationId: string;
name: string;
address: string;
phone?: string;
openTime: string;
closeTime: string;
createdAt: Date;
}
export interface Court {
id: string;
siteId: string;
name: string;
type: CourtType;
pricePerHour: number;
premiumPrice?: number;
status: CourtStatus;
}
export interface User {
id: string;
organizationId: string;
siteId?: string;
email: string;
name: string;
phone?: string;
role: UserRole;
createdAt: Date;
}
export interface Client {
id: string;
organizationId: string;
email: string;
name: string;
phone?: string;
photo?: string;
balance: number;
createdAt: Date;
}
export interface Booking {
id: string;
courtId: string;
clientId: string;
date: Date;
startTime: string;
endTime: string;
price: number;
status: BookingStatus;
paymentType?: PaymentType;
isPaid: boolean;
notes?: string;
createdAt: Date;
createdBy?: string;
}
export interface MembershipPlan {
id: string;
organizationId: string;
name: string;
price: number;
freeHours: number;
bookingDiscount: number;
storeDiscount: number;
extraBenefits?: string;
}
export interface Membership {
id: string;
clientId: string;
planId: string;
startDate: Date;
endDate: Date;
hoursUsed: number;
status: MembershipStatus;
}
export interface ProductCategory {
id: string;
name: string;
}
export interface Product {
id: string;
siteId: string;
categoryId: string;
name: string;
price: number;
stock: number;
minStock: number;
}
export interface Tournament {
id: string;
siteId: string;
name: string;
description?: string;
date: Date;
endDate?: Date;
type: TournamentType;
category?: string;
maxTeams: number;
price: number;
status: TournamentStatus;
}
Step 4: Crear validaciones con Zod
Create packages/shared/src/validations/index.ts:
import { z } from "zod";
// Auth
export const loginSchema = z.object({
email: z.string().email("Email inválido"),
password: z.string().min(6, "Mínimo 6 caracteres"),
});
export const registerClientSchema = z.object({
email: z.string().email("Email inválido"),
password: z.string().min(6, "Mínimo 6 caracteres"),
name: z.string().min(2, "Nombre muy corto"),
phone: z.string().optional(),
});
// Booking
export const createBookingSchema = z.object({
courtId: z.string().cuid(),
clientId: z.string().cuid(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Formato: YYYY-MM-DD"),
startTime: z.string().regex(/^\d{2}:\d{2}$/, "Formato: HH:MM"),
endTime: z.string().regex(/^\d{2}:\d{2}$/, "Formato: HH:MM"),
notes: z.string().optional(),
});
export const updateBookingStatusSchema = z.object({
status: z.enum(["PENDING", "CONFIRMED", "CANCELLED", "COMPLETED"]),
paymentType: z.enum(["CASH", "TRANSFER", "CARD_TERMINAL"]).optional(),
isPaid: z.boolean().optional(),
});
// Site
export const createSiteSchema = z.object({
name: z.string().min(2, "Nombre muy corto"),
address: z.string().min(5, "Dirección muy corta"),
phone: z.string().optional(),
openTime: z.string().regex(/^\d{2}:\d{2}$/, "Formato: HH:MM"),
closeTime: z.string().regex(/^\d{2}:\d{2}$/, "Formato: HH:MM"),
});
// Court
export const createCourtSchema = z.object({
siteId: z.string().cuid(),
name: z.string().min(1, "Nombre requerido"),
type: z.enum(["SINGLES", "DOUBLES", "MIXED"]),
pricePerHour: z.number().positive("Precio debe ser positivo"),
premiumPrice: z.number().positive().optional(),
});
// Product
export const createProductSchema = z.object({
siteId: z.string().cuid(),
categoryId: z.string().cuid(),
name: z.string().min(1, "Nombre requerido"),
price: z.number().positive("Precio debe ser positivo"),
stock: z.number().int().min(0, "Stock no puede ser negativo"),
minStock: z.number().int().min(0).default(5),
});
// Sale
export const createSaleSchema = z.object({
siteId: z.string().cuid(),
items: z.array(z.object({
productId: z.string().cuid(),
quantity: z.number().int().positive(),
price: z.number().positive(),
})).min(1, "Mínimo un producto"),
payments: z.array(z.object({
amount: z.number().positive(),
method: z.enum(["CASH", "TRANSFER", "CARD_TERMINAL"]),
reference: z.string().optional(),
})).min(1, "Mínimo un pago"),
});
// Tournament
export const createTournamentSchema = z.object({
siteId: z.string().cuid(),
name: z.string().min(2, "Nombre muy corto"),
description: z.string().optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Formato: YYYY-MM-DD"),
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
type: z.enum(["SINGLE_ELIMINATION", "DOUBLE_ELIMINATION", "ROUND_ROBIN", "LEAGUE"]),
category: z.string().optional(),
maxTeams: z.number().int().positive(),
price: z.number().min(0),
});
// Membership Plan
export const createMembershipPlanSchema = z.object({
name: z.string().min(2, "Nombre muy corto"),
price: z.number().positive("Precio debe ser positivo"),
freeHours: z.number().int().min(0),
bookingDiscount: z.number().int().min(0).max(100),
storeDiscount: z.number().int().min(0).max(100),
extraBenefits: z.string().optional(),
});
// Export types from schemas
export type LoginInput = z.infer<typeof loginSchema>;
export type RegisterClientInput = z.infer<typeof registerClientSchema>;
export type CreateBookingInput = z.infer<typeof createBookingSchema>;
export type UpdateBookingStatusInput = z.infer<typeof updateBookingStatusSchema>;
export type CreateSiteInput = z.infer<typeof createSiteSchema>;
export type CreateCourtInput = z.infer<typeof createCourtSchema>;
export type CreateProductInput = z.infer<typeof createProductSchema>;
export type CreateSaleInput = z.infer<typeof createSaleSchema>;
export type CreateTournamentInput = z.infer<typeof createTournamentSchema>;
export type CreateMembershipPlanInput = z.infer<typeof createMembershipPlanSchema>;
Step 5: Crear index.ts
Create packages/shared/src/index.ts:
export * from "./types";
export * from "./validations";
Step 6: Commit
git add -A
git commit -m "feat(shared): add types and Zod validations"
Task 4: Configurar Prisma y Base de Datos
Files:
- Create:
apps/web/prisma/schema.prisma - Create:
apps/web/.env.example - Modify:
apps/web/package.json
Step 1: Agregar dependencias Prisma
Update apps/web/package.json dependencies:
{
"dependencies": {
"@prisma/client": "^5.10.0"
},
"devDependencies": {
"prisma": "^5.10.0"
},
"scripts": {
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:studio": "prisma studio",
"db:seed": "tsx prisma/seed.ts"
}
}
Step 2: Crear schema.prisma completo
Create apps/web/prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ============ ORGANIZATION & SITES ============
model Organization {
id String @id @default(cuid())
name String
logo String?
sites Site[]
users User[]
clients Client[]
membershipPlans MembershipPlan[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Site {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
address String
phone String?
openTime String @default("08:00")
closeTime String @default("22:00")
courts Court[]
users User[]
products Product[]
sales Sale[]
tournaments Tournament[]
cashRegisters CashRegister[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
}
model Court {
id String @id @default(cuid())
siteId String
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
name String
type CourtType @default(DOUBLES)
pricePerHour Decimal @db.Decimal(10, 2)
premiumPrice Decimal? @db.Decimal(10, 2)
status CourtStatus @default(ACTIVE)
bookings Booking[]
matches Match[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([siteId])
}
enum CourtType {
SINGLES
DOUBLES
MIXED
}
enum CourtStatus {
ACTIVE
MAINTENANCE
INACTIVE
}
// ============ USERS & CLIENTS ============
model User {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
siteId String?
site Site? @relation(fields: [siteId], references: [id])
email String @unique
password String
name String
phone String?
role UserRole
payments Payment[]
salesCreated Sale[]
cashRegistersOpened CashRegister[] @relation("OpenedBy")
cashRegistersClosed CashRegister[] @relation("ClosedBy")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([siteId])
}
enum UserRole {
SUPER_ADMIN
SITE_ADMIN
RECEPTIONIST
}
model Client {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
email String @unique
password String
name String
phone String?
photo String?
balance Decimal @default(0) @db.Decimal(10, 2)
membership Membership?
bookings Booking[]
tournamentInscriptions TournamentInscription[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
}
// ============ BOOKINGS ============
model Booking {
id String @id @default(cuid())
courtId String
court Court @relation(fields: [courtId], references: [id], onDelete: Cascade)
clientId String
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
date DateTime @db.Date
startTime String
endTime String
price Decimal @db.Decimal(10, 2)
status BookingStatus @default(PENDING)
paymentType PaymentType?
isPaid Boolean @default(false)
notes String?
payments Payment[]
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([courtId, date, startTime])
@@index([courtId])
@@index([clientId])
@@index([date])
}
enum BookingStatus {
PENDING
CONFIRMED
CANCELLED
COMPLETED
}
enum PaymentType {
CASH
TRANSFER
CARD_TERMINAL
}
// ============ PAYMENTS ============
model Payment {
id String @id @default(cuid())
amount Decimal @db.Decimal(10, 2)
method PaymentType
reference String?
bookingId String?
booking Booking? @relation(fields: [bookingId], references: [id])
saleId String?
sale Sale? @relation(fields: [saleId], references: [id])
createdById String
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
@@index([bookingId])
@@index([saleId])
}
// ============ MEMBERSHIPS ============
model MembershipPlan {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
price Decimal @db.Decimal(10, 2)
freeHours Int
bookingDiscount Int @default(0)
storeDiscount Int @default(0)
extraBenefits String?
isActive Boolean @default(true)
memberships Membership[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
}
model Membership {
id String @id @default(cuid())
clientId String @unique
client Client @relation(fields: [clientId], references: [id], onDelete: Cascade)
planId String
plan MembershipPlan @relation(fields: [planId], references: [id])
startDate DateTime @db.Date
endDate DateTime @db.Date
hoursUsed Int @default(0)
status MembershipStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([planId])
}
enum MembershipStatus {
ACTIVE
EXPIRED
CANCELLED
}
// ============ POINT OF SALE ============
model ProductCategory {
id String @id @default(cuid())
name String @unique
products Product[]
}
model Product {
id String @id @default(cuid())
siteId String
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
categoryId String
category ProductCategory @relation(fields: [categoryId], references: [id])
name String
price Decimal @db.Decimal(10, 2)
stock Int @default(0)
minStock Int @default(5)
isActive Boolean @default(true)
saleItems SaleItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([siteId])
@@index([categoryId])
}
model Sale {
id String @id @default(cuid())
siteId String
site Site @relation(fields: [siteId], references: [id])
items SaleItem[]
total Decimal @db.Decimal(10, 2)
payments Payment[]
createdById String
createdBy User @relation(fields: [createdById], references: [id])
createdAt DateTime @default(now())
@@index([siteId])
@@index([createdAt])
}
model SaleItem {
id String @id @default(cuid())
saleId String
sale Sale @relation(fields: [saleId], references: [id], onDelete: Cascade)
productId String
product Product @relation(fields: [productId], references: [id])
quantity Int
price Decimal @db.Decimal(10, 2)
@@index([saleId])
}
model CashRegister {
id String @id @default(cuid())
siteId String
site Site @relation(fields: [siteId], references: [id])
openedById String
openedBy User @relation("OpenedBy", fields: [openedById], references: [id])
closedById String?
closedBy User? @relation("ClosedBy", fields: [closedById], references: [id])
openingAmount Decimal @db.Decimal(10, 2)
closingAmount Decimal? @db.Decimal(10, 2)
expectedAmount Decimal? @db.Decimal(10, 2)
notes String?
openedAt DateTime @default(now())
closedAt DateTime?
status CashRegisterStatus @default(OPEN)
@@index([siteId])
}
enum CashRegisterStatus {
OPEN
CLOSED
}
// ============ TOURNAMENTS ============
model Tournament {
id String @id @default(cuid())
siteId String
site Site @relation(fields: [siteId], references: [id], onDelete: Cascade)
name String
description String?
date DateTime @db.Date
endDate DateTime? @db.Date
type TournamentType
category String?
maxTeams Int
price Decimal @db.Decimal(10, 2)
status TournamentStatus @default(DRAFT)
inscriptions TournamentInscription[]
matches Match[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([siteId])
}
enum TournamentType {
SINGLE_ELIMINATION
DOUBLE_ELIMINATION
ROUND_ROBIN
LEAGUE
}
enum TournamentStatus {
DRAFT
OPEN
IN_PROGRESS
FINISHED
CANCELLED
}
model TournamentInscription {
id String @id @default(cuid())
tournamentId String
tournament Tournament @relation(fields: [tournamentId], references: [id], onDelete: Cascade)
player1Id String
player1 Client @relation(fields: [player1Id], references: [id])
player2Id String?
teamName String?
isPaid Boolean @default(false)
createdAt DateTime @default(now())
@@unique([tournamentId, player1Id])
@@index([tournamentId])
}
model Match {
id String @id @default(cuid())
tournamentId String
tournament Tournament @relation(fields: [tournamentId], references: [id], onDelete: Cascade)
round Int
position Int
team1Id String?
team2Id String?
score1 String?
score2 String?
winnerId String?
courtId String?
court Court? @relation(fields: [courtId], references: [id])
scheduledAt DateTime?
status MatchStatus @default(PENDING)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tournamentId])
}
enum MatchStatus {
PENDING
IN_PROGRESS
FINISHED
}
Step 3: Crear .env.example
Create apps/web/.env.example:
# Database
DATABASE_URL="postgresql://user:password@localhost:5432/padel_pro?schema=public"
# Auth
NEXTAUTH_SECRET="your-secret-key-here"
NEXTAUTH_URL="http://localhost:3000"
# App
NEXT_PUBLIC_APP_URL="http://localhost:3000"
Step 4: Crear lib/db.ts
Create apps/web/lib/db.ts:
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log:
process.env.NODE_ENV === "development"
? ["query", "error", "warn"]
: ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = db;
Step 5: Commit
git add -A
git commit -m "feat(db): add Prisma schema with all models"
Task 5: Configurar Autenticación con NextAuth.js
Files:
- Create:
apps/web/lib/auth.ts - Create:
apps/web/app/api/auth/[...nextauth]/route.ts - Create:
apps/web/middleware.ts - Create:
apps/web/components/providers/auth-provider.tsx
Step 1: Instalar dependencias
Add to apps/web/package.json:
{
"dependencies": {
"next-auth": "^4.24.0",
"bcryptjs": "^2.4.3"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6"
}
}
Step 2: Crear configuración de auth
Create apps/web/lib/auth.ts:
import { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { compare } from "bcryptjs";
import { db } from "./db";
export const authOptions: NextAuthOptions = {
session: {
strategy: "jwt",
},
pages: {
signIn: "/login",
},
providers: [
CredentialsProvider({
id: "admin-login",
name: "Admin Login",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await db.user.findUnique({
where: { email: credentials.email },
include: { organization: true, site: true },
});
if (!user) {
return null;
}
const isPasswordValid = await compare(
credentials.password,
user.password
);
if (!isPasswordValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
organizationId: user.organizationId,
organizationName: user.organization.name,
siteId: user.siteId,
siteName: user.site?.name,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
token.organizationId = user.organizationId;
token.organizationName = user.organizationName;
token.siteId = user.siteId;
token.siteName = user.siteName;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
session.user.organizationId = token.organizationId as string;
session.user.organizationName = token.organizationName as string;
session.user.siteId = token.siteId as string | undefined;
session.user.siteName = token.siteName as string | undefined;
}
return session;
},
},
};
Step 3: Crear types para NextAuth
Create apps/web/types/next-auth.d.ts:
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
role: string;
organizationId: string;
organizationName: string;
siteId?: string;
siteName?: string;
} & DefaultSession["user"];
}
interface User {
id: string;
role: string;
organizationId: string;
organizationName: string;
siteId?: string;
siteName?: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string;
role: string;
organizationId: string;
organizationName: string;
siteId?: string;
siteName?: string;
}
}
Step 4: Crear API route
Create apps/web/app/api/auth/[...nextauth]/route.ts:
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
Step 5: Crear middleware
Create apps/web/middleware.ts:
import { withAuth } from "next-auth/middleware";
import { NextResponse } from "next/server";
export default withAuth(
function middleware(req) {
const token = req.nextauth.token;
const path = req.nextUrl.pathname;
// Check role-based access
if (path.startsWith("/admin/settings") && token?.role !== "SUPER_ADMIN") {
return NextResponse.redirect(new URL("/admin/dashboard", req.url));
}
return NextResponse.next();
},
{
callbacks: {
authorized: ({ token }) => !!token,
},
}
);
export const config = {
matcher: ["/admin/:path*"],
};
Step 6: Crear AuthProvider
Create apps/web/components/providers/auth-provider.tsx:
"use client";
import { SessionProvider } from "next-auth/react";
interface AuthProviderProps {
children: React.ReactNode;
}
export function AuthProvider({ children }: AuthProviderProps) {
return <SessionProvider>{children}</SessionProvider>;
}
Step 7: Commit
git add -A
git commit -m "feat(auth): add NextAuth.js with credentials provider"
Fase 2: Componentes UI Base
Task 6: Instalar y Configurar shadcn/ui
Files:
- Create:
apps/web/components.json - Create:
apps/web/lib/utils.ts - Create:
apps/web/components/ui/button.tsx - Create:
apps/web/components/ui/input.tsx - Create:
apps/web/components/ui/card.tsx - Create:
apps/web/components/ui/dialog.tsx - Create:
apps/web/components/ui/table.tsx
Step 1: Crear components.json
Create apps/web/components.json:
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}
Step 2: Crear lib/utils.ts
Create apps/web/lib/utils.ts:
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function formatCurrency(amount: number): string {
return new Intl.NumberFormat("es-MX", {
style: "currency",
currency: "MXN",
}).format(amount);
}
export function formatDate(date: Date | string): string {
return new Intl.DateTimeFormat("es-MX", {
day: "2-digit",
month: "short",
year: "numeric",
}).format(new Date(date));
}
export function formatTime(time: string): string {
const [hours, minutes] = time.split(":");
const hour = parseInt(hours);
const ampm = hour >= 12 ? "PM" : "AM";
const hour12 = hour % 12 || 12;
return `${hour12}:${minutes} ${ampm}`;
}
Step 3: Agregar dependencias de shadcn
Add to apps/web/package.json:
{
"dependencies": {
"clsx": "^2.1.0",
"tailwind-merge": "^2.2.0",
"class-variance-authority": "^0.7.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"lucide-react": "^0.330.0"
}
}
Step 4: Crear Button component
Create apps/web/components/ui/button.tsx:
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-white hover:bg-primary-600",
destructive: "bg-red-500 text-white hover:bg-red-600",
outline: "border border-slate-200 bg-white hover:bg-slate-100",
secondary: "bg-slate-100 text-slate-900 hover:bg-slate-200",
ghost: "hover:bg-slate-100",
link: "text-primary underline-offset-4 hover:underline",
accent: "bg-accent text-white hover:bg-accent-600",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-lg px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };
Step 5: Crear Input component
Create apps/web/components/ui/input.tsx:
import * as React from "react";
import { cn } from "@/lib/utils";
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm ring-offset-white file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";
export { Input };
Step 6: Crear Card components
Create apps/web/components/ui/card.tsx:
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border border-slate-200 bg-white text-slate-950 shadow-sm",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-slate-500", className)}
{...props}
/>
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
Step 7: Commit
git add -A
git commit -m "feat(ui): add shadcn/ui base components"
Task 7: Crear Layout del Admin Panel
Files:
- Create:
apps/web/app/(admin)/layout.tsx - Create:
apps/web/components/layout/sidebar.tsx - Create:
apps/web/components/layout/header.tsx - Create:
apps/web/components/layout/site-switcher.tsx
Step 1: Crear Sidebar
Create apps/web/components/layout/sidebar.tsx:
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
import {
LayoutDashboard,
Calendar,
Trophy,
ShoppingCart,
Users,
CreditCard,
BarChart3,
Settings,
} from "lucide-react";
const navigation = [
{ name: "Dashboard", href: "/admin/dashboard", icon: LayoutDashboard },
{ name: "Reservas", href: "/admin/bookings", icon: Calendar },
{ name: "Torneos", href: "/admin/tournaments", icon: Trophy },
{ name: "Ventas", href: "/admin/pos", icon: ShoppingCart },
{ name: "Clientes", href: "/admin/clients", icon: Users },
{ name: "Membresías", href: "/admin/memberships", icon: CreditCard },
{ name: "Reportes", href: "/admin/reports", icon: BarChart3 },
{ name: "Configuración", href: "/admin/settings", icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
return (
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r border-slate-200 bg-white">
<div className="flex h-16 items-center border-b border-slate-200 px-6">
<Link href="/admin/dashboard" className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-white font-bold">
P
</div>
<span className="text-xl font-bold text-primary">Padel Pro</span>
</Link>
</div>
<nav className="flex flex-col gap-1 p-4">
{navigation.map((item) => {
const isActive = pathname.startsWith(item.href);
return (
<Link
key={item.name}
href={item.href}
className={cn(
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-white"
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900"
)}
>
<item.icon className="h-5 w-5" />
{item.name}
</Link>
);
})}
</nav>
</aside>
);
}
Step 2: Crear Header
Create apps/web/components/layout/header.tsx:
"use client";
import { useSession, signOut } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { SiteSwitcher } from "./site-switcher";
import { LogOut, User } from "lucide-react";
export function Header() {
const { data: session } = useSession();
return (
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b border-slate-200 bg-white px-6">
<div className="flex items-center gap-4">
<SiteSwitcher />
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm">
<User className="h-4 w-4 text-slate-400" />
<span className="font-medium">{session?.user?.name}</span>
<span className="text-slate-400">·</span>
<span className="text-slate-500">{session?.user?.role}</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => signOut({ callbackUrl: "/login" })}
>
<LogOut className="h-4 w-4" />
</Button>
</div>
</header>
);
}
Step 3: Crear SiteSwitcher
Create apps/web/components/layout/site-switcher.tsx:
"use client";
import { useState, useEffect } from "react";
import { useSession } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { ChevronDown, MapPin } from "lucide-react";
interface Site {
id: string;
name: string;
}
export function SiteSwitcher() {
const { data: session } = useSession();
const [sites, setSites] = useState<Site[]>([]);
const [currentSite, setCurrentSite] = useState<Site | null>(null);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
// Fetch sites from API
async function fetchSites() {
const res = await fetch("/api/sites");
if (res.ok) {
const data = await res.json();
setSites(data);
if (data.length > 0 && !currentSite) {
setCurrentSite(data[0]);
}
}
}
fetchSites();
}, []);
// For SUPER_ADMIN, show site switcher
// For others, show their assigned site
if (session?.user?.role !== "SUPER_ADMIN" && session?.user?.siteName) {
return (
<div className="flex items-center gap-2 text-sm">
<MapPin className="h-4 w-4 text-accent" />
<span className="font-medium">{session.user.siteName}</span>
</div>
);
}
return (
<div className="relative">
<Button
variant="outline"
className="flex items-center gap-2"
onClick={() => setIsOpen(!isOpen)}
>
<MapPin className="h-4 w-4 text-accent" />
<span>{currentSite?.name || "Todas las sedes"}</span>
<ChevronDown className="h-4 w-4" />
</Button>
{isOpen && (
<div className="absolute left-0 top-full mt-2 w-56 rounded-lg border border-slate-200 bg-white py-1 shadow-lg">
<button
className="w-full px-4 py-2 text-left text-sm hover:bg-slate-100"
onClick={() => {
setCurrentSite(null);
setIsOpen(false);
}}
>
Todas las sedes
</button>
{sites.map((site) => (
<button
key={site.id}
className="w-full px-4 py-2 text-left text-sm hover:bg-slate-100"
onClick={() => {
setCurrentSite(site);
setIsOpen(false);
}}
>
{site.name}
</button>
))}
</div>
)}
</div>
);
}
Step 4: Crear Admin Layout
Create apps/web/app/(admin)/layout.tsx:
import { AuthProvider } from "@/components/providers/auth-provider";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthProvider>
<div className="min-h-screen bg-slate-50">
<Sidebar />
<div className="pl-64">
<Header />
<main className="p-6">{children}</main>
</div>
</div>
</AuthProvider>
);
}
Step 5: Commit
git add -A
git commit -m "feat(layout): add admin panel layout with sidebar and header"
Fase 3: Módulo de Reservas (Core)
Task 8: Crear API de Canchas y Disponibilidad
Files:
- Create:
apps/web/app/api/courts/route.ts - Create:
apps/web/app/api/courts/[id]/route.ts - Create:
apps/web/app/api/courts/[id]/availability/route.ts
Step 1: Crear API de listado de canchas
Create apps/web/app/api/courts/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db";
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const siteId = searchParams.get("siteId");
const where: any = {
site: {
organizationId: session.user.organizationId,
},
};
if (siteId) {
where.siteId = siteId;
} else if (session.user.siteId) {
where.siteId = session.user.siteId;
}
const courts = await db.court.findMany({
where,
include: {
site: {
select: { id: true, name: true },
},
},
orderBy: { name: "asc" },
});
return NextResponse.json(courts);
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user || !["SUPER_ADMIN", "SITE_ADMIN"].includes(session.user.role)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const court = await db.court.create({
data: {
siteId: body.siteId,
name: body.name,
type: body.type,
pricePerHour: body.pricePerHour,
premiumPrice: body.premiumPrice,
},
});
return NextResponse.json(court, { status: 201 });
}
Step 2: Crear API de disponibilidad
Create apps/web/app/api/courts/[id]/availability/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db";
interface TimeSlot {
time: string;
available: boolean;
price: number;
bookingId?: string;
}
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const date = searchParams.get("date");
if (!date) {
return NextResponse.json({ error: "Date required" }, { status: 400 });
}
const court = await db.court.findUnique({
where: { id: params.id },
include: { site: true },
});
if (!court) {
return NextResponse.json({ error: "Court not found" }, { status: 404 });
}
// Get existing bookings for this date
const bookings = await db.booking.findMany({
where: {
courtId: params.id,
date: new Date(date),
status: { in: ["PENDING", "CONFIRMED"] },
},
});
// Generate time slots
const slots: TimeSlot[] = [];
const openHour = parseInt(court.site.openTime.split(":")[0]);
const closeHour = parseInt(court.site.closeTime.split(":")[0]);
for (let hour = openHour; hour < closeHour; hour++) {
const time = `${hour.toString().padStart(2, "0")}:00`;
const booking = bookings.find((b) => b.startTime === time);
// Premium hours: after 18:00 or weekends
const dateObj = new Date(date);
const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6;
const isPremium = hour >= 18 || isWeekend;
const price = isPremium && court.premiumPrice
? Number(court.premiumPrice)
: Number(court.pricePerHour);
slots.push({
time,
available: !booking,
price,
bookingId: booking?.id,
});
}
return NextResponse.json({
court,
date,
slots,
});
}
Step 3: Commit
git add -A
git commit -m "feat(api): add courts and availability endpoints"
Task 9: Crear API de Reservas
Files:
- Create:
apps/web/app/api/bookings/route.ts - Create:
apps/web/app/api/bookings/[id]/route.ts - Create:
apps/web/app/api/bookings/[id]/pay/route.ts
Step 1: Crear API de reservas
Create apps/web/app/api/bookings/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db";
import { createBookingSchema } from "@padel-pro/shared";
export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { searchParams } = new URL(req.url);
const siteId = searchParams.get("siteId");
const date = searchParams.get("date");
const status = searchParams.get("status");
const where: any = {
court: {
site: {
organizationId: session.user.organizationId,
},
},
};
if (siteId) {
where.court = { ...where.court, siteId };
} else if (session.user.siteId) {
where.court = { ...where.court, siteId: session.user.siteId };
}
if (date) {
where.date = new Date(date);
}
if (status) {
where.status = status;
}
const bookings = await db.booking.findMany({
where,
include: {
court: {
include: { site: { select: { name: true } } },
},
client: {
select: { id: true, name: true, email: true, phone: true },
},
},
orderBy: [{ date: "asc" }, { startTime: "asc" }],
});
return NextResponse.json(bookings);
}
export async function POST(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const validation = createBookingSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: validation.error.errors },
{ status: 400 }
);
}
const { courtId, clientId, date, startTime, endTime, notes } = validation.data;
// Check court exists and get price
const court = await db.court.findUnique({
where: { id: courtId },
include: { site: true },
});
if (!court) {
return NextResponse.json({ error: "Court not found" }, { status: 404 });
}
// Check availability
const existingBooking = await db.booking.findFirst({
where: {
courtId,
date: new Date(date),
startTime,
status: { in: ["PENDING", "CONFIRMED"] },
},
});
if (existingBooking) {
return NextResponse.json(
{ error: "Time slot not available" },
{ status: 409 }
);
}
// Calculate price
const dateObj = new Date(date);
const hour = parseInt(startTime.split(":")[0]);
const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6;
const isPremium = hour >= 18 || isWeekend;
const price = isPremium && court.premiumPrice
? Number(court.premiumPrice)
: Number(court.pricePerHour);
// Check client membership for discounts
const client = await db.client.findUnique({
where: { id: clientId },
include: {
membership: {
include: { plan: true },
},
},
});
let finalPrice = price;
if (client?.membership?.status === "ACTIVE") {
const plan = client.membership.plan;
// Check if has free hours
if (client.membership.hoursUsed < plan.freeHours) {
finalPrice = 0;
// Update hours used
await db.membership.update({
where: { id: client.membership.id },
data: { hoursUsed: { increment: 1 } },
});
} else if (plan.bookingDiscount > 0) {
finalPrice = price * (1 - plan.bookingDiscount / 100);
}
}
const booking = await db.booking.create({
data: {
courtId,
clientId,
date: new Date(date),
startTime,
endTime,
price: finalPrice,
notes,
createdBy: session.user.id,
},
include: {
court: true,
client: true,
},
});
return NextResponse.json(booking, { status: 201 });
}
Step 2: Crear API de pago de reserva
Create apps/web/app/api/bookings/[id]/pay/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { db } from "@/lib/db";
export async function POST(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await req.json();
const { paymentType, amount, reference } = body;
const booking = await db.booking.findUnique({
where: { id: params.id },
});
if (!booking) {
return NextResponse.json({ error: "Booking not found" }, { status: 404 });
}
// Create payment and update booking
const [payment, updatedBooking] = await db.$transaction([
db.payment.create({
data: {
amount: amount || booking.price,
method: paymentType,
reference,
bookingId: params.id,
createdById: session.user.id,
},
}),
db.booking.update({
where: { id: params.id },
data: {
status: "CONFIRMED",
paymentType,
isPaid: true,
},
include: {
court: true,
client: true,
},
}),
]);
return NextResponse.json({ booking: updatedBooking, payment });
}
Step 3: Commit
git add -A
git commit -m "feat(api): add bookings CRUD and payment endpoints"
Task 10: Crear UI de Calendario de Reservas
Files:
- Create:
apps/web/app/(admin)/bookings/page.tsx - Create:
apps/web/components/bookings/booking-calendar.tsx - Create:
apps/web/components/bookings/booking-dialog.tsx - Create:
apps/web/components/bookings/time-slot.tsx
Step 1: Crear componente TimeSlot
Create apps/web/components/bookings/time-slot.tsx:
"use client";
import { cn, formatCurrency, formatTime } from "@/lib/utils";
interface TimeSlotProps {
time: string;
available: boolean;
price: number;
clientName?: string;
onClick: () => void;
}
export function TimeSlot({
time,
available,
price,
clientName,
onClick,
}: TimeSlotProps) {
return (
<button
onClick={onClick}
disabled={!available && !clientName}
className={cn(
"h-16 w-full rounded-lg border-2 p-2 text-left transition-all",
available
? "border-accent/30 bg-accent/5 hover:border-accent hover:bg-accent/10"
: "border-slate-200 bg-slate-50",
clientName && "border-primary/30 bg-primary/5 hover:border-primary"
)}
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{formatTime(time)}</span>
{available && (
<span className="text-xs text-accent font-medium">
{formatCurrency(price)}
</span>
)}
</div>
{clientName && (
<p className="mt-1 truncate text-xs text-slate-600">{clientName}</p>
)}
{available && (
<p className="mt-1 text-xs text-slate-400">Disponible</p>
)}
</button>
);
}
Step 2: Crear BookingCalendar
Create apps/web/components/bookings/booking-calendar.tsx:
"use client";
import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { TimeSlot } from "./time-slot";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface Court {
id: string;
name: string;
type: string;
}
interface Slot {
time: string;
available: boolean;
price: number;
bookingId?: string;
clientName?: string;
}
interface BookingCalendarProps {
siteId?: string;
onSlotClick: (courtId: string, date: string, slot: Slot) => void;
}
export function BookingCalendar({ siteId, onSlotClick }: BookingCalendarProps) {
const [date, setDate] = useState(new Date());
const [courts, setCourts] = useState<Court[]>([]);
const [availability, setAvailability] = useState<Record<string, Slot[]>>({});
const [loading, setLoading] = useState(true);
const dateStr = date.toISOString().split("T")[0];
useEffect(() => {
async function fetchData() {
setLoading(true);
// Fetch courts
const courtsRes = await fetch(
`/api/courts${siteId ? `?siteId=${siteId}` : ""}`
);
const courtsData = await courtsRes.json();
setCourts(courtsData);
// Fetch availability for each court
const availabilityData: Record<string, Slot[]> = {};
await Promise.all(
courtsData.map(async (court: Court) => {
const res = await fetch(
`/api/courts/${court.id}/availability?date=${dateStr}`
);
const data = await res.json();
availabilityData[court.id] = data.slots;
})
);
setAvailability(availabilityData);
setLoading(false);
}
fetchData();
}, [siteId, dateStr]);
const prevDay = () => {
const newDate = new Date(date);
newDate.setDate(newDate.getDate() - 1);
setDate(newDate);
};
const nextDay = () => {
const newDate = new Date(date);
newDate.setDate(newDate.getDate() + 1);
setDate(newDate);
};
const today = () => {
setDate(new Date());
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Calendario de Reservas</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" onClick={prevDay}>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={today}>
Hoy
</Button>
<Button variant="outline" size="icon" onClick={nextDay}>
<ChevronRight className="h-4 w-4" />
</Button>
<span className="ml-4 text-lg font-medium">
{date.toLocaleDateString("es-MX", {
weekday: "long",
day: "numeric",
month: "long",
})}
</span>
</div>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-64 items-center justify-center">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
) : (
<div className="grid gap-6" style={{ gridTemplateColumns: `repeat(${courts.length}, 1fr)` }}>
{courts.map((court) => (
<div key={court.id}>
<h3 className="mb-4 text-center font-semibold text-primary">
{court.name}
</h3>
<div className="space-y-2">
{availability[court.id]?.map((slot) => (
<TimeSlot
key={slot.time}
time={slot.time}
available={slot.available}
price={slot.price}
clientName={slot.clientName}
onClick={() => onSlotClick(court.id, dateStr, slot)}
/>
))}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
Step 3: Crear página de reservas
Create apps/web/app/(admin)/bookings/page.tsx:
"use client";
import { useState } from "react";
import { BookingCalendar } from "@/components/bookings/booking-calendar";
import { BookingDialog } from "@/components/bookings/booking-dialog";
interface Slot {
time: string;
available: boolean;
price: number;
bookingId?: string;
}
export default function BookingsPage() {
const [selectedSlot, setSelectedSlot] = useState<{
courtId: string;
date: string;
slot: Slot;
} | null>(null);
const handleSlotClick = (courtId: string, date: string, slot: Slot) => {
setSelectedSlot({ courtId, date, slot });
};
const handleClose = () => {
setSelectedSlot(null);
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-slate-900">Reservas</h1>
<p className="text-slate-500">
Gestiona las reservas de canchas
</p>
</div>
<BookingCalendar onSlotClick={handleSlotClick} />
{selectedSlot && (
<BookingDialog
courtId={selectedSlot.courtId}
date={selectedSlot.date}
slot={selectedSlot.slot}
onClose={handleClose}
/>
)}
</div>
);
}
Step 4: Crear BookingDialog
Create apps/web/components/bookings/booking-dialog.tsx:
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { formatCurrency, formatTime } from "@/lib/utils";
import { X, Search, User } from "lucide-react";
interface Slot {
time: string;
available: boolean;
price: number;
bookingId?: string;
}
interface BookingDialogProps {
courtId: string;
date: string;
slot: Slot;
onClose: () => void;
}
interface Client {
id: string;
name: string;
email: string;
phone?: string;
}
export function BookingDialog({
courtId,
date,
slot,
onClose,
}: BookingDialogProps) {
const [searchQuery, setSearchQuery] = useState("");
const [clients, setClients] = useState<Client[]>([]);
const [selectedClient, setSelectedClient] = useState<Client | null>(null);
const [loading, setLoading] = useState(false);
const [creating, setCreating] = useState(false);
const searchClients = async () => {
if (searchQuery.length < 2) return;
setLoading(true);
const res = await fetch(`/api/clients?search=${searchQuery}`);
const data = await res.json();
setClients(data);
setLoading(false);
};
const createBooking = async () => {
if (!selectedClient) return;
setCreating(true);
const endHour = parseInt(slot.time.split(":")[0]) + 1;
const endTime = `${endHour.toString().padStart(2, "0")}:00`;
const res = await fetch("/api/bookings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
courtId,
clientId: selectedClient.id,
date,
startTime: slot.time,
endTime,
}),
});
if (res.ok) {
onClose();
window.location.reload();
}
setCreating(false);
};
if (!slot.available) {
// Show booking details instead
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<Card className="w-full max-w-md">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Reserva existente</CardTitle>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<p className="text-slate-600">
Este horario ya está reservado.
</p>
<div className="mt-4 flex gap-2">
<Button variant="outline" onClick={onClose}>
Cerrar
</Button>
<Button variant="destructive">
Cancelar reserva
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<Card className="w-full max-w-md">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>Nueva Reserva</CardTitle>
<Button variant="ghost" size="icon" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg bg-slate-50 p-4">
<p className="text-sm text-slate-500">Horario seleccionado</p>
<p className="text-lg font-semibold">{formatTime(slot.time)}</p>
<p className="text-accent font-medium">{formatCurrency(slot.price)}</p>
</div>
{!selectedClient ? (
<>
<div className="flex gap-2">
<Input
placeholder="Buscar cliente por nombre o teléfono..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && searchClients()}
/>
<Button onClick={searchClients} disabled={loading}>
<Search className="h-4 w-4" />
</Button>
</div>
<div className="max-h-48 space-y-2 overflow-auto">
{clients.map((client) => (
<button
key={client.id}
className="flex w-full items-center gap-3 rounded-lg border p-3 text-left hover:bg-slate-50"
onClick={() => setSelectedClient(client)}
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<User className="h-5 w-5 text-primary" />
</div>
<div>
<p className="font-medium">{client.name}</p>
<p className="text-sm text-slate-500">{client.phone || client.email}</p>
</div>
</button>
))}
</div>
<Button variant="outline" className="w-full">
+ Crear nuevo cliente
</Button>
</>
) : (
<>
<div className="flex items-center gap-3 rounded-lg border p-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<User className="h-5 w-5 text-primary" />
</div>
<div className="flex-1">
<p className="font-medium">{selectedClient.name}</p>
<p className="text-sm text-slate-500">
{selectedClient.phone || selectedClient.email}
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setSelectedClient(null)}
>
Cambiar
</Button>
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={onClose}
>
Cancelar
</Button>
<Button
className="flex-1"
onClick={createBooking}
disabled={creating}
>
{creating ? "Creando..." : "Crear Reserva"}
</Button>
</div>
</>
)}
</CardContent>
</Card>
</div>
);
}
Step 5: Commit
git add -A
git commit -m "feat(bookings): add calendar UI with booking creation"
Fase 4: Continuar con POS, Torneos y Mobile...
(El plan continúa con las siguientes tareas para completar todos los módulos)
Task 11-15: Módulo POS
- API de productos y categorías
- API de ventas
- Control de caja
- UI de punto de venta
Task 16-20: Módulo Torneos
- API de torneos
- Sistema de brackets
- Inscripciones
- UI de gestión de torneos
Task 21-25: Módulo Membresías
- API de planes
- Gestión de membresías
- Descuentos automáticos
- UI de membresías
Task 26-30: Dashboard y Reportes
- Estadísticas del día
- Reportes de ventas
- Ocupación de canchas
- Gráficos y métricas
Task 31-40: App Mobile (React Native)
- Setup de Expo
- Autenticación de clientes
- Pantalla de reservas
- Mis reservas
- Torneos
- Perfil
Plan generado - Febrero 2026