diff --git a/docs/plans/2026-02-01-padel-pro-implementation.md b/docs/plans/2026-02-01-padel-pro-implementation.md
new file mode 100644
index 0000000..76ac4d2
--- /dev/null
+++ b/docs/plans/2026-02-01-padel-pro-implementation.md
@@ -0,0 +1,2857 @@
+# 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**
+
+```bash
+cd /root/Padel
+pnpm init
+```
+
+**Step 2: Configurar pnpm workspace**
+
+Create `pnpm-workspace.yaml`:
+```yaml
+packages:
+ - "apps/*"
+ - "packages/*"
+```
+
+**Step 3: Configurar Turborepo**
+
+Create `turbo.json`:
+```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**
+
+```json
+{
+ "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**
+
+```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**
+
+```bash
+mkdir -p apps/web apps/mobile packages/shared
+```
+
+**Step 8: Commit**
+
+```bash
+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`:
+```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`:
+```javascript
+/** @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`:
+```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`:
+```typescript
+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`:
+```javascript
+module.exports = {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+};
+```
+
+**Step 6: Crear globals.css**
+
+Create `apps/web/app/globals.css`:
+```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`:
+```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 (
+
+
{children}
+
+ );
+}
+```
+
+**Step 8: Crear page.tsx inicial**
+
+Create `apps/web/app/page.tsx`:
+```tsx
+export default function Home() {
+ return (
+
+
+
+ Padel Pro
+
+
+ Sistema de Gestión para Clubes de Pádel
+
+
+
+ Dashboard
+
+
+ Reservas
+
+
+
+
+ );
+}
+```
+
+**Step 9: Commit**
+
+```bash
+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`:
+```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`:
+```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`:
+```typescript
+// 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`:
+```typescript
+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;
+export type RegisterClientInput = z.infer;
+export type CreateBookingInput = z.infer;
+export type UpdateBookingStatusInput = z.infer;
+export type CreateSiteInput = z.infer;
+export type CreateCourtInput = z.infer;
+export type CreateProductInput = z.infer;
+export type CreateSaleInput = z.infer;
+export type CreateTournamentInput = z.infer;
+export type CreateMembershipPlanInput = z.infer;
+```
+
+**Step 5: Crear index.ts**
+
+Create `packages/shared/src/index.ts`:
+```typescript
+export * from "./types";
+export * from "./validations";
+```
+
+**Step 6: Commit**
+
+```bash
+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:
+```json
+{
+ "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`:
+```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`:
+```env
+# 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`:
+```typescript
+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**
+
+```bash
+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`:
+```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`:
+```typescript
+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`:
+```typescript
+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`:
+```typescript
+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`:
+```typescript
+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`:
+```typescript
+"use client";
+
+import { SessionProvider } from "next-auth/react";
+
+interface AuthProviderProps {
+ children: React.ReactNode;
+}
+
+export function AuthProvider({ children }: AuthProviderProps) {
+ return {children} ;
+}
+```
+
+**Step 7: Commit**
+
+```bash
+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`:
+```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`:
+```typescript
+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`:
+```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`:
+```typescript
+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,
+ VariantProps {
+ asChild?: boolean;
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+ return (
+
+ );
+ }
+);
+Button.displayName = "Button";
+
+export { Button, buttonVariants };
+```
+
+**Step 5: Crear Input component**
+
+Create `apps/web/components/ui/input.tsx`:
+```typescript
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+export interface InputProps
+ extends React.InputHTMLAttributes {}
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Input.displayName = "Input";
+
+export { Input };
+```
+
+**Step 6: Crear Card components**
+
+Create `apps/web/components/ui/card.tsx`:
+```typescript
+import * as React from "react";
+import { cn } from "@/lib/utils";
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+Card.displayName = "Card";
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardHeader.displayName = "CardHeader";
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardTitle.displayName = "CardTitle";
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardDescription.displayName = "CardDescription";
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardContent.displayName = "CardContent";
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+CardFooter.displayName = "CardFooter";
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
+```
+
+**Step 7: Commit**
+
+```bash
+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`:
+```typescript
+"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 (
+
+
+
+
+ P
+
+
Padel Pro
+
+
+
+
+ {navigation.map((item) => {
+ const isActive = pathname.startsWith(item.href);
+ return (
+
+
+ {item.name}
+
+ );
+ })}
+
+
+ );
+}
+```
+
+**Step 2: Crear Header**
+
+Create `apps/web/components/layout/header.tsx`:
+```typescript
+"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 (
+
+ );
+}
+```
+
+**Step 3: Crear SiteSwitcher**
+
+Create `apps/web/components/layout/site-switcher.tsx`:
+```typescript
+"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([]);
+ const [currentSite, setCurrentSite] = useState(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 (
+
+
+ {session.user.siteName}
+
+ );
+ }
+
+ return (
+
+
setIsOpen(!isOpen)}
+ >
+
+ {currentSite?.name || "Todas las sedes"}
+
+
+
+ {isOpen && (
+
+ {
+ setCurrentSite(null);
+ setIsOpen(false);
+ }}
+ >
+ Todas las sedes
+
+ {sites.map((site) => (
+ {
+ setCurrentSite(site);
+ setIsOpen(false);
+ }}
+ >
+ {site.name}
+
+ ))}
+
+ )}
+
+ );
+}
+```
+
+**Step 4: Crear Admin Layout**
+
+Create `apps/web/app/(admin)/layout.tsx`:
+```typescript
+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 (
+
+
+
+ );
+}
+```
+
+**Step 5: Commit**
+
+```bash
+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`:
+```typescript
+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`:
+```typescript
+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**
+
+```bash
+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`:
+```typescript
+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`:
+```typescript
+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**
+
+```bash
+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`:
+```typescript
+"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 (
+
+
+ {formatTime(time)}
+ {available && (
+
+ {formatCurrency(price)}
+
+ )}
+
+ {clientName && (
+ {clientName}
+ )}
+ {available && (
+ Disponible
+ )}
+
+ );
+}
+```
+
+**Step 2: Crear BookingCalendar**
+
+Create `apps/web/components/bookings/booking-calendar.tsx`:
+```typescript
+"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([]);
+ const [availability, setAvailability] = useState>({});
+ 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 = {};
+ 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 (
+
+
+
+
Calendario de Reservas
+
+
+
+
+
+ Hoy
+
+
+
+
+
+ {date.toLocaleDateString("es-MX", {
+ weekday: "long",
+ day: "numeric",
+ month: "long",
+ })}
+
+
+
+
+
+ {loading ? (
+
+ ) : (
+
+ {courts.map((court) => (
+
+
+ {court.name}
+
+
+ {availability[court.id]?.map((slot) => (
+ onSlotClick(court.id, dateStr, slot)}
+ />
+ ))}
+
+
+ ))}
+
+ )}
+
+
+ );
+}
+```
+
+**Step 3: Crear página de reservas**
+
+Create `apps/web/app/(admin)/bookings/page.tsx`:
+```typescript
+"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 (
+
+
+
Reservas
+
+ Gestiona las reservas de canchas
+
+
+
+
+
+ {selectedSlot && (
+
+ )}
+
+ );
+}
+```
+
+**Step 4: Crear BookingDialog**
+
+Create `apps/web/components/bookings/booking-dialog.tsx`:
+```typescript
+"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([]);
+ const [selectedClient, setSelectedClient] = useState(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 (
+
+
+
+ Reserva existente
+
+
+
+
+
+
+ Este horario ya está reservado.
+
+
+
+ Cerrar
+
+
+ Cancelar reserva
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ Nueva Reserva
+
+
+
+
+
+
+
Horario seleccionado
+
{formatTime(slot.time)}
+
{formatCurrency(slot.price)}
+
+
+ {!selectedClient ? (
+ <>
+
+ setSearchQuery(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && searchClients()}
+ />
+
+
+
+
+
+
+ {clients.map((client) => (
+
setSelectedClient(client)}
+ >
+
+
+
+
+
{client.name}
+
{client.phone || client.email}
+
+
+ ))}
+
+
+
+ + Crear nuevo cliente
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+
{selectedClient.name}
+
+ {selectedClient.phone || selectedClient.email}
+
+
+
setSelectedClient(null)}
+ >
+ Cambiar
+
+
+
+
+
+ Cancelar
+
+
+ {creating ? "Creando..." : "Crear Reserva"}
+
+
+ >
+ )}
+
+
+
+ );
+}
+```
+
+**Step 5: Commit**
+
+```bash
+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*