Files
app-padel/docs/plans/2026-02-01-padel-pro-implementation.md
Ivan 8dbd20950e docs: add detailed implementation plan
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>
2026-02-01 06:06:35 +00:00

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